Files
3x-ui/internal/web/service/client_crud.go
T
n0ctal b8903fadf4 feat(clients): renew on a calendar day instead of a rolling interval (#6239)
* feat(clients): renew on a calendar day instead of a rolling interval

Auto-renew advances the expiry by a fixed number of milliseconds, so a client
set to 30 days drifts against the calendar: renewing on 31 January lands on
2 March, and by the end of the year the billing day has wandered a fortnight
from where the operator's own plan resets.

Add a per-client renewal day. When set, the expiry steps whole calendar months
at midnight in the panel's time zone. A month too short for the chosen day
renews on its last day and the following month returns to the chosen one, so
the 31st does not decay into the 28th permanently.

Zero keeps the interval mode, so existing clients are untouched.

The interval branch now also refuses a zero step. It is unreachable while the
selection filter holds, but that loop runs on the single traffic writer, and a
zero interval there would hang every panel mutation behind it.

* fix(clients): persist the calendar renewal day on the client record

resetDay lived only in the inbound settings JSON and client_traffics, so
every path that rebuilds a client from the clients table wrote it back as
zero: an ordinary edit, an attach to a second inbound, a traffic reset on
a disabled client. Calendar mode turned itself off during normal use and
the operator only found out a month later.

Adds reset_day to ClientRecord and threads it through ToRecord, ToClient,
applyClientRecordMerge and the record update map, so the value survives
the round trip. The clients page filter and ClientSlim now recognise the
mode, nodeClientRenewed classifies a calendar renewal as a renewal, the
node snapshot merge carries reset_day, and the service layer rejects a
day outside 0-31 rather than clamping it silently.

Also renames the label keys to renewOnDay to keep them apart from the
existing renewDays, translates them and the new RESET_DAY subscription
placeholder in all 13 locales, adds the field to the bulk-add modal, and
drops the stray internal/web/dist/.gitkeep build stub.

* fix(clients): let the billing day be changed after creation

ClientService.Update writes the record columns directly only for a client
with no inbounds. The normal path goes through SyncInbound and
applyClientRecordMerge, which this change had not extended, so moving a
client from the 20th to the 5th updated the inbound settings JSON while
clients.reset_day kept the old value and the renewal kept using it.

The existing test did not catch it: it asserted the day survived an
unrelated edit, and it survived precisely because nothing on that path
ever wrote it. TestClientEditChangesTheBillingDay moves the day and then
switches calendar mode off again; removing the record write turns it red.

* chore: drop the accidentally committed dist build stub

internal/web/dist/.gitkeep is what make dist-stub creates locally. Committing
it changes fresh-clone behaviour for everyone: today a bare go build fails
loudly on //go:embed all:dist, which is the documented signal to run the stub
target; with the file present the build succeeds and the panel serves an empty
dist instead.

---------

Co-authored-by: n0ctal <n0ctal@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-08-18 12:46:00 +02:00

850 lines
24 KiB
Go

package service
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"unicode"
"github.com/google/uuid"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
"github.com/mhsanaei/3x-ui/v3/internal/util/random"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"gorm.io/gorm"
)
func hasForbiddenClientChar(s string) bool {
for _, r := range s {
if r == '/' || r == '\\' || r < 0x20 || r == 0x7f || unicode.IsSpace(r) {
return true
}
}
return false
}
func validateClientEmail(email string) error {
if hasForbiddenClientChar(email) {
return common.NewError("client email contains an invalid character:", email)
}
return nil
}
func validateClientSubID(subID string) error {
if hasForbiddenClientChar(subID) {
return common.NewError("client subId contains an invalid character:", subID)
}
return nil
}
// Rejected rather than clamped: nextCalendarRenewal would silently move an
// out-of-range day, and a negative one drops out of the renewal query entirely.
func validateClientResetDay(day int) error {
if day < 0 || day > 31 {
return common.NewError("client resetDay must be between 0 and 31, got:", day)
}
return nil
}
// Rejected rather than coerced: a negative cap reads as "unlimited" to a caller
// but selects nothing, so the client would silently stop renewing.
func validateClientResetMax(resetMax int) error {
if resetMax < 0 {
return common.NewError("client resetMax must not be negative, got:", resetMax)
}
return nil
}
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 err := validateClientEmail(client.Email); err != nil {
return false, err
}
if err := validateClientSubID(client.SubID); err != nil {
return false, err
}
if err := validateClientResetDay(client.ResetDay); err != nil {
return false, err
}
if err := validateClientResetMax(client.ResetMax); err != nil {
return false, err
}
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)
}
// Reuse stored credentials when re-adding an existing identity, or
// fillProtocolDefaults mints a fresh UUID that desyncs other inbounds.
if client.ID == "" {
client.ID = existing.UUID
}
if client.Password == "" {
client.Password = existing.Password
}
if client.Auth == "" {
client.Auth = existing.Auth
}
if client.Secret == "" {
client.Secret = existing.Secret
}
}
if client.SubID != "" {
var subTaken int64
if err := database.GetDB().Model(&model.ClientRecord{}).
Where("sub_id = ? AND email <> ?", client.SubID, client.Email).
Count(&subTaken).Error; err != nil {
return false, err
}
if subTaken > 0 {
return false, common.NewError("subId already in use:", client.SubID)
}
}
emailSubIDs, sidErr := inboundSvc.getAllEmailSubIDs()
if sidErr != nil {
return false, sidErr
}
needRestart := false
for _, ibId := range payload.InboundIds {
inbound, getErr := inboundSvc.GetInbound(ibId)
if getErr != nil {
return needRestart, getErr
}
if err := s.fillProtocolDefaults(&client, inbound); err != nil {
return needRestart, err
}
settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {clientWithInboundFlow(client, inbound)}})
if mErr != nil {
return needRestart, mErr
}
nr, addErr := s.addInboundClient(inboundSvc, &model.Inbound{
Id: ibId,
Settings: string(settingsPayload),
}, emailSubIDs)
if addErr != nil {
return needRestart, addErr
}
if nr {
needRestart = true
}
}
if err := s.setClientLimitHwidByEmail(nil, client.Email, payload.LimitHwid); err != nil {
return needRestart, err
}
return needRestart, nil
}
func (s *ClientService) fillProtocolDefaults(c *model.Client, ib *model.Inbound) error {
switch ib.Protocol {
case model.VMESS, model.VLESS:
if c.ID == "" {
c.ID = uuid.NewString()
}
case model.Trojan:
if c.Password == "" {
c.Password = strings.ReplaceAll(uuid.NewString(), "-", "")
}
case model.Shadowsocks:
method := shadowsocksMethodFromSettings(ib.Settings)
if c.Password == "" || !validShadowsocksClientKey(method, c.Password) {
c.Password = randomShadowsocksClientKey(method)
}
case model.Hysteria:
if c.Auth == "" {
c.Auth = strings.ReplaceAll(uuid.NewString(), "-", "")
}
case model.MTProto:
if c.Secret == "" {
c.Secret = model.GenerateFakeTLSSecret(mtprotoDomainFromSettings(ib.Settings))
}
}
return nil
}
// defaultMtprotoDomain is the FakeTLS fronting domain used when an mtproto
// inbound carries no fakeTlsDomain of its own; it mirrors the frontend default.
const defaultMtprotoDomain = "www.cloudflare.com"
// mtprotoDomainFromSettings returns the inbound-level FakeTLS domain, falling
// back to the default when unset, so a generated client secret always fronts a
// real hostname.
func mtprotoDomainFromSettings(settings string) string {
domain := ""
if settings != "" {
var m map[string]any
if err := json.Unmarshal([]byte(settings), &m); err == nil {
domain, _ = m["fakeTlsDomain"].(string)
}
}
domain = strings.TrimSpace(domain)
if domain == "" {
return defaultMtprotoDomain
}
return domain
}
func clientWithInboundFlow(c model.Client, ib *model.Inbound) model.Client {
if ib.DisableFlow || !inboundCanEnableTlsFlow(string(ib.Protocol), ib.StreamSettings, ib.Settings) {
c.Flow = ""
}
return c
}
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
}
func randomShadowsocksClientKey(method string) string {
if n := shadowsocksKeyBytes(method); n > 0 {
return random.Base64Bytes(n)
}
return strings.ReplaceAll(uuid.NewString(), "-", "")
}
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
}
// normalizeShadowsocksClientKeys rewrites any Shadowsocks-2022 client password
// whose decoded length no longer matches settings.method, which happens after the
// inbound method is switched between ciphers of different key sizes (e.g.
// aes-256↔aes-128). A wrong-length uPSK makes xray reject the user, so the link
// fails to connect; regenerating restores a valid key (clients must re-fetch).
// Non-Shadowsocks / legacy-SS settings pass through unchanged.
func normalizeShadowsocksClientKeys(settings string) (string, bool) {
method := shadowsocksMethodFromSettings(settings)
if shadowsocksKeyBytes(method) == 0 {
return settings, false
}
var m map[string]any
if err := json.Unmarshal([]byte(settings), &m); err != nil {
return settings, false
}
clients, ok := m["clients"].([]any)
if !ok {
return settings, false
}
changed := false
for i := range clients {
c, ok := clients[i].(map[string]any)
if !ok {
continue
}
if pw, _ := c["password"].(string); validShadowsocksClientKey(method, pw) {
continue
}
c["password"] = randomShadowsocksClientKey(method)
clients[i] = c
changed = true
}
if !changed {
return settings, false
}
m["clients"] = clients
bs, err := json.MarshalIndent(m, "", " ")
if err != nil {
return settings, false
}
return string(bs), true
}
func applyShadowsocksClientMethod(clients []any, settings map[string]any) {
method, _ := settings["method"].(string)
is2022 := strings.HasPrefix(method, "2022-blake3-")
for i := range clients {
cm, ok := clients[i].(map[string]any)
if !ok {
continue
}
if is2022 {
if _, hasKey := cm["method"]; hasKey {
delete(cm, "method")
clients[i] = cm
}
continue
}
if method == "" {
continue
}
if existing, _ := cm["method"].(string); existing != "" {
continue
}
cm["method"] = method
clients[i] = cm
}
}
func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model.Client, limitHwid int, inboundFilter ...int) (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 len(inboundFilter) > 0 {
allow := make(map[int]struct{}, len(inboundFilter))
for _, fid := range inboundFilter {
allow[fid] = struct{}{}
}
filtered := inboundIds[:0:0]
for _, ibId := range inboundIds {
if _, ok := allow[ibId]; ok {
filtered = append(filtered, ibId)
}
}
inboundIds = filtered
}
if strings.TrimSpace(updated.Email) == "" {
return false, common.NewError("client email is required")
}
if err := validateClientEmail(updated.Email); err != nil {
return false, err
}
if err := validateClientSubID(updated.SubID); err != nil {
return false, err
}
if err := validateClientResetDay(updated.ResetDay); err != nil {
return false, err
}
if err := validateClientResetMax(updated.ResetMax); err != nil {
return false, err
}
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
}
// Preserve existing credentials when the caller omits them, so a partial
// update (e.g. only changing traffic/expiry) doesn't silently rotate the
// client's UUID/password/auth via fillProtocolDefaults. Supplying a new
// value still rotates it intentionally.
if updated.ID == "" {
updated.ID = existing.UUID
}
if updated.Password == "" {
updated.Password = existing.Password
}
if updated.Auth == "" {
updated.Auth = existing.Auth
}
if updated.Secret == "" {
updated.Secret = existing.Secret
}
if updated.Email != existing.Email {
var collisionCount int64
if err := database.GetDB().Model(&model.ClientRecord{}).
Where("email = ? AND id <> ?", updated.Email, id).
Count(&collisionCount).Error; err != nil {
return false, err
}
if collisionCount > 0 {
return false, common.NewError("Duplicate email:", updated.Email)
}
}
if updated.SubID != existing.SubID {
var subCollision int64
if err := database.GetDB().Model(&model.ClientRecord{}).
Where("sub_id = ? AND id <> ?", updated.SubID, id).
Count(&subCollision).Error; err != nil {
return false, err
}
if subCollision > 0 {
return false, common.NewError("Duplicate subId:", updated.SubID)
}
}
needRestart := false
for _, ibId := range inboundIds {
inbound, getErr := inboundSvc.GetInbound(ibId)
if getErr != nil {
if errors.Is(getErr, gorm.ErrRecordNotFound) {
if err := database.GetDB().
Where("client_id = ? AND inbound_id = ?", id, ibId).
Delete(&model.ClientInbound{}).Error; err != nil {
return needRestart, err
}
continue
}
return needRestart, getErr
}
if existing.Email == "" {
continue
}
if err := s.fillProtocolDefaults(&updated, inbound); err != nil {
return needRestart, err
}
settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {clientWithInboundFlow(updated, inbound)}})
if mErr != nil {
return needRestart, mErr
}
nr, upErr := s.UpdateInboundClient(inboundSvc, &model.Inbound{
Id: ibId,
Settings: string(settingsPayload),
}, existing.Email)
if upErr != nil {
return needRestart, upErr
}
if nr {
needRestart = true
}
}
// UpdateInboundClient renames the record atomically with each inbound's
// settings JSON; this direct write only covers records with no inbound left.
if updated.Email != existing.Email {
if err := database.GetDB().Model(&model.ClientRecord{}).
Where("id = ? AND email = ?", id, existing.Email).
Update("email", updated.Email).Error; err != nil {
return needRestart, err
}
}
if len(inboundIds) == 0 {
merged := *existing
applyClientRecordMerge(&merged, updated.ToRecord())
if err := database.GetDB().Model(&model.ClientRecord{}).
Where("id = ?", id).
Updates(map[string]any{
"sub_id": merged.SubID,
"uuid": merged.UUID,
"password": merged.Password,
"auth": merged.Auth,
"secret": merged.Secret,
"flow": merged.Flow,
"security": merged.Security,
"wg_private_key": merged.PrivateKey,
"wg_public_key": merged.PublicKey,
"wg_allowed_ips": merged.AllowedIPs,
"wg_pre_shared_key": merged.PreSharedKey,
"wg_keep_alive": merged.KeepAlive,
"limit_ip": merged.LimitIP,
"total_gb": merged.TotalGB,
"expiry_time": merged.ExpiryTime,
"tg_id": merged.TgID,
"comment": merged.Comment,
"reset": merged.Reset,
"reset_day": merged.ResetDay,
"reset_max": merged.ResetMax,
}).Error; err != nil {
return needRestart, err
}
}
reverseStr := ""
if updated.Reverse != nil && strings.TrimSpace(updated.Reverse.Tag) != "" {
if b, mErr := json.Marshal(updated.Reverse); mErr == nil {
reverseStr = string(b)
}
}
if err := database.GetDB().Model(&model.ClientRecord{}).
Where("id = ?", id).
Update("reverse", reverseStr).Error; err != nil {
return needRestart, err
}
// Persist the group explicitly. SyncInbound deliberately preserves the
// stored group when the inbound settings carry none — so a node snapshot or a
// group-less settings rebuild can't wipe it (see SyncInbound + its tests).
// That guard also meant clearing the group in the client editor never took
// effect. The editor always round-trips the field, so apply it here,
// including the empty string that removes the client from its group.
if err := database.GetDB().Model(&model.ClientRecord{}).
Where("id = ?", id).
UpdateColumn("group_name", updated.Group).Error; err != nil {
return needRestart, err
}
// Same shape as the group write above: SyncInbound keeps a stored ad-tag
// when the incoming settings carry none, so clearing the override must be
// applied here, where the editor always round-trips the field.
if err := database.GetDB().Model(&model.ClientRecord{}).
Where("id = ?", id).
UpdateColumn("ad_tag", updated.AdTag).Error; err != nil {
return needRestart, err
}
if err := database.GetDB().Model(&model.ClientRecord{}).
Where("id = ?", id).
UpdateColumn("enable", updated.Enable).Error; err != nil {
return needRestart, err
}
if err := s.setClientLimitHwidByEmail(nil, updated.Email, limitHwid); err != nil {
return needRestart, err
}
if err := database.GetDB().Model(&model.ClientRecord{}).
Where("id = ?", id).
UpdateColumn("updated_at", time.Now().UnixMilli()).Error; err != nil {
return needRestart, err
}
return needRestart, nil
}
func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic bool) (bool, error) {
existing, err := s.GetByID(id)
if err != nil {
return false, err
}
tombstoneClientEmail(existing.Email)
inboundIds, err := s.GetInboundIdsForRecord(id)
if err != nil {
withdrawClientTombstones(existing.Email)
return false, err
}
needRestart := false
var delErrs []error
for _, ibId := range inboundIds {
if _, getErr := inboundSvc.GetInbound(ibId); getErr != nil {
if errors.Is(getErr, gorm.ErrRecordNotFound) {
continue
}
delErrs = append(delErrs, fmt.Errorf("inbound %d: %w", ibId, getErr))
continue
}
// Always delete by email — the client's stable identity. This removes
// every matching entry from the inbound's settings even when the stored
// credential (UUID/password/auth) drifted from the inbound JSON, or a
// duplicate entry with the same email exists.
if existing.Email == "" {
continue
}
nr, delErr := s.DelInboundClientByEmail(inboundSvc, ibId, existing.Email, keepTraffic, true)
if delErr != nil {
// The client is already absent from this inbound (data drift or a
// retried delete). Skip it — deletion stays idempotent.
if errors.Is(delErr, ErrClientNotInInbound) {
continue
}
delErrs = append(delErrs, fmt.Errorf("inbound %d: %w", ibId, delErr))
continue
}
if nr {
needRestart = true
}
}
// A failed inbound still holds the client in its settings JSON: keep the
// record so the next delete retries exactly the leftovers, and report it.
// The tombstone lifts with it, or the next node merge finishes the deletion.
if len(delErrs) > 0 {
withdrawClientTombstones(existing.Email)
return needRestart, errors.Join(delErrs...)
}
db := database.GetDB()
if err := db.Transaction(func(tx *gorm.DB) error {
if existing.Email != "" {
if err := adjustGroupBaselinesForRemovedTraffic(tx, []string{existing.Email}); err != nil {
return err
}
}
if err := tx.Where("client_id = ?", id).Delete(&model.ClientInbound{}).Error; err != nil {
return err
}
if err := tx.Where("client_id = ?", id).Delete(&model.ClientExternalLink{}).Error; err != nil {
return err
}
if err := clearClientHwidsBySubIDTx(tx, existing.SubID); err != nil {
return err
}
if !keepTraffic && existing.Email != "" {
if err := tx.Where("email = ?", existing.Email).Delete(&xray.ClientTraffic{}).Error; err != nil {
return err
}
if err := clearGlobalTraffic(tx, existing.Email); err != nil {
return err
}
if err := tx.Where("client_email = ?", existing.Email).Delete(&model.InboundClientIps{}).Error; err != nil {
return err
}
if err := tx.Where("email = ?", existing.Email).Delete(&model.NodeClientTraffic{}).Error; err != nil {
return err
}
}
return tx.Delete(&model.ClientRecord{}, id).Error
}); err != nil {
withdrawClientTombstones(existing.Email)
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()
flow, ffErr := s.EffectiveFlow(nil, id)
if ffErr != nil {
return false, ffErr
}
clientWire.Flow = flow
clientWire.UpdatedAt = time.Now().UnixMilli()
emailSubIDs, sidErr := inboundSvc.getAllEmailSubIDs()
if sidErr != nil {
return false, sidErr
}
needRestart := false
for _, ibId := range inboundIds {
if _, attached := have[ibId]; attached {
continue
}
inbound, getErr := inboundSvc.GetInbound(ibId)
if getErr != nil {
return needRestart, getErr
}
copyClient := *clientWire
if err := s.fillProtocolDefaults(&copyClient, inbound); err != nil {
return needRestart, err
}
settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {clientWithInboundFlow(copyClient, inbound)}})
if mErr != nil {
return needRestart, mErr
}
nr, addErr := s.addInboundClient(inboundSvc, &model.Inbound{
Id: ibId,
Settings: string(settingsPayload),
}, emailSubIDs)
if addErr != nil {
return needRestart, addErr
}
if nr {
needRestart = true
}
}
return needRestart, nil
}
func (s *ClientService) CreateOne(inboundSvc *InboundService, inboundId int, client model.Client) (bool, error) {
return s.Create(inboundSvc, &ClientCreatePayload{
Client: client,
InboundIds: []int{inboundId},
})
}
func (s *ClientService) DetachByEmail(inboundSvc *InboundService, inboundId int, email string) (bool, error) {
if email == "" {
return false, common.NewError("client email is required")
}
rec, err := s.GetRecordByEmail(nil, email)
if err != nil {
return false, err
}
return s.Detach(inboundSvc, rec.Id, []int{inboundId})
}
func (s *ClientService) AttachByEmail(inboundSvc *InboundService, email string, inboundIds []int) (bool, error) {
if email == "" {
return false, common.NewError("client email is required")
}
rec, err := s.GetRecordByEmail(nil, email)
if err != nil {
return false, err
}
return s.Attach(inboundSvc, rec.Id, inboundIds)
}
func (s *ClientService) DetachByEmailMany(inboundSvc *InboundService, email string, inboundIds []int) (bool, error) {
if email == "" {
return false, common.NewError("client email is required")
}
rec, err := s.GetRecordByEmail(nil, email)
if err != nil {
return false, err
}
return s.Detach(inboundSvc, rec.Id, inboundIds)
}
func (s *ClientService) DeleteByEmail(inboundSvc *InboundService, email string, keepTraffic bool) (bool, error) {
if email == "" {
return false, common.NewError("client email is required")
}
rec, err := s.GetRecordByEmail(nil, email)
if err == nil {
return s.Delete(inboundSvc, rec.Id, keepTraffic)
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return false, err
}
inboundIds, idsErr := s.findInboundIdsByClientEmail(email)
if idsErr != nil {
return false, idsErr
}
if len(inboundIds) == 0 {
return false, common.NewError(fmt.Sprintf("client %q not found in any inbound or client record", email))
}
needRestart := false
var delErrs []error
for _, ibId := range inboundIds {
nr, delErr := s.DelInboundClientByEmail(inboundSvc, ibId, email, keepTraffic, true)
if delErr != nil {
if errors.Is(delErr, ErrClientNotInInbound) {
continue
}
delErrs = append(delErrs, fmt.Errorf("inbound %d: %w", ibId, delErr))
continue
}
if nr {
needRestart = true
}
}
if len(delErrs) > 0 {
return needRestart, errors.Join(delErrs...)
}
if !keepTraffic {
db := database.GetDB()
if err := db.Where("email = ?", email).Delete(&xray.ClientTraffic{}).Error; err != nil {
return needRestart, err
}
if err := clearGlobalTraffic(db, email); err != nil {
return needRestart, err
}
if err := db.Where("client_email = ?", email).Delete(&model.InboundClientIps{}).Error; err != nil {
return needRestart, err
}
if err := db.Where("email = ?", email).Delete(&model.NodeClientTraffic{}).Error; err != nil {
return needRestart, err
}
}
return needRestart, nil
}
func (s *ClientService) UpdateByEmail(inboundSvc *InboundService, email string, updated model.Client, limitHwid int, inboundFilter ...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.Update(inboundSvc, rec.Id, updated, limitHwid, inboundFilter...)
}
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
}
if _, getErr := inboundSvc.GetInbound(ibId); getErr != nil {
return needRestart, getErr
}
// Detach by email — the client's stable identity (see Delete).
if existing.Email == "" {
continue
}
nr, delErr := s.DelInboundClientByEmail(inboundSvc, ibId, existing.Email, true, false)
if delErr != nil {
if errors.Is(delErr, ErrClientNotInInbound) {
continue
}
return needRestart, delErr
}
if nr {
needRestart = true
}
}
return needRestart, nil
}