mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-07 02:37:14 +00:00
d34ec97f62
Editing, deleting or detaching a client on a master with several nodes took one node round-trip per node, added end to end. Create and Attach already fanned their per-inbound applies out through fanoutInboundClientAdds, but Update, Delete, Detach and DeleteByEmail's record-less fallback still walked their inbounds in a plain sequential loop, and each iteration blocks on a node RPC (10s timeout, more when a node is slow or has just gone unreachable and the heartbeat has not marked it offline yet). Measured with a node runtime injecting 100ms per RPC, before: nodes=1 create=101ms update=101ms delete=101ms nodes=3 create=102ms update=303ms delete=302ms nodes=5 create=202ms update=504ms delete=504ms after, all three track create: nodes=3 create=102ms update=102ms delete=101ms nodes=5 create=203ms update=203ms delete=203ms Generalize the existing helper into fanoutInboundApplies over an inboundApply list and route the four remaining loops through it, so they inherit the same concurrency cap, per-inbound panic recovery and joined errors. Each caller still builds its payloads sequentially first: fillProtocolDefaults mints the shared credentials on the first inbound and every later one reuses them, so that order has to stay deterministic. Only the applies overlap; their DB work still serializes through the single traffic writer, and the per-inbound mutation lock is unchanged, which is exactly what Create has relied on. Behaviour change: one failing inbound no longer aborts the remaining ones, matching what Create already does. The error still names each failed inbound and the record-level writes are still skipped when any inbound failed. The snapshot merge on the same serialized writer was measured as a second suspect and cleared: ~43ms per node at 500 clients, an order of magnitude below the RPC serialization.
1054 lines
33 KiB
Go
1054 lines
33 KiB
Go
package service
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/netip"
|
|
"runtime/debug"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"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/logger"
|
|
"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 coerced: an unknown cycle would leave the operator with
|
|
// a field that reads as configured while no job ever selects the client.
|
|
func validateClientTrafficReset(period string, day int) error {
|
|
switch period {
|
|
case "", "never", "hourly", "daily", "weekly", "monthly":
|
|
default:
|
|
return common.NewError("client trafficReset must be never, hourly, daily, weekly or monthly, got:", period)
|
|
}
|
|
if day < 0 || day > 31 {
|
|
return common.NewError("client trafficResetDay must be between 0 and 31, got:", day)
|
|
}
|
|
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
|
|
}
|
|
|
|
// normalizeClientTrafficReset stores what the inbound path would store, so the
|
|
// day never reaches the DB as a 0 that three layers downstream each clamp to 1.
|
|
func normalizeClientTrafficReset(c *model.Client) {
|
|
if c.TrafficReset == "" {
|
|
c.TrafficReset = "never"
|
|
}
|
|
c.TrafficResetDay = normalizeTrafficResetDay(c.TrafficResetDay)
|
|
}
|
|
|
|
// ClientResetCycle is the slice of a client the reset job needs: enough to know
|
|
// whether it is due, and whether its disable is the quota's doing or the operator's.
|
|
type ClientResetCycle struct {
|
|
Email string
|
|
TrafficResetDay int
|
|
Enable bool
|
|
Total int64
|
|
Used int64
|
|
}
|
|
|
|
// Depleted reports a client the quota switched off. A reset restores that one;
|
|
// a client disabled below its quota was switched off by hand and stays off.
|
|
func (c ClientResetCycle) Depleted() bool {
|
|
return c.Total > 0 && c.Used >= c.Total
|
|
}
|
|
|
|
// GetClientsByTrafficReset returns the clients whose own reset cycle matches the
|
|
// period, independent of the cycle configured on the inbounds they belong to.
|
|
func (s *ClientService) GetClientsByTrafficReset(period string) ([]ClientResetCycle, error) {
|
|
var cycles []ClientResetCycle
|
|
err := database.GetDB().Table("clients c").
|
|
Select("c.email, c.traffic_reset_day, c.enable, COALESCE(ct.total, 0) AS total, COALESCE(ct.up, 0) + COALESCE(ct.down, 0) AS used").
|
|
Joins("LEFT JOIN client_traffics ct ON ct.email = c.email").
|
|
Where("c.traffic_reset = ?", period).
|
|
Scan(&cycles).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return cycles, nil
|
|
}
|
|
|
|
// Create applies the client to every requested inbound: one failing inbound no
|
|
// longer aborts the others, so the error can name several and needRestart holds.
|
|
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 err := validateClientTrafficReset(client.TrafficReset, client.TrafficResetDay); err != nil {
|
|
return false, err
|
|
}
|
|
normalizeClientTrafficReset(&client)
|
|
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)
|
|
}
|
|
}
|
|
|
|
// Prepared before any inbound is written: fillProtocolDefaults mints the
|
|
// shared credentials on the first inbound and every later one reuses them.
|
|
adds := make([]*model.Inbound, 0, len(payload.InboundIds))
|
|
for _, ibId := range payload.InboundIds {
|
|
inbound, getErr := inboundSvc.GetInbound(ibId)
|
|
if getErr != nil {
|
|
return false, fmt.Errorf("inbound %d: %w", ibId, getErr)
|
|
}
|
|
if err := s.fillProtocolDefaults(&client, inbound); err != nil {
|
|
return false, fmt.Errorf("inbound %d: %w", ibId, err)
|
|
}
|
|
clientForInbound := client
|
|
if ips, ok := client.AllowedIPsByInbound[ibId]; ok {
|
|
clientForInbound.AllowedIPs = ips
|
|
} else if !addressesFitAmneziaWGInbound(clientForInbound.AllowedIPs, inbound) {
|
|
// The shared AllowedIPs value (e.g. from a single-field legacy
|
|
// caller) came from a different subnet than this inbound's own --
|
|
// clear it so defaultAmneziaWGClients allocates a fresh, correct
|
|
// address for THIS inbound instead of persisting an unroutable
|
|
// peer. Same reasoning as addressesFitAmneziaWGInbound's own doc
|
|
// comment on the Attach path.
|
|
clientForInbound.AllowedIPs = nil
|
|
}
|
|
settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {clientWithInboundFlow(clientForInbound, inbound)}})
|
|
if mErr != nil {
|
|
return false, fmt.Errorf("inbound %d: %w", ibId, mErr)
|
|
}
|
|
adds = append(adds, &model.Inbound{Id: ibId, Settings: string(settingsPayload)})
|
|
}
|
|
needRestart, fanoutErr := s.fanoutInboundClientAdds(inboundSvc, adds)
|
|
if fanoutErr != nil {
|
|
// Never on a failed create: this retrims the devices of an email that
|
|
// already existed, and a create the panel reported as failed must not.
|
|
return needRestart, fanoutErr
|
|
}
|
|
return needRestart, s.setClientLimitHwidByEmail(nil, client.Email, payload.LimitHwid)
|
|
}
|
|
|
|
// inboundFanoutConcurrency caps how many inbounds one client op applies at
|
|
// once, so a client spanning many of them can't start an unbounded RPC burst.
|
|
const inboundFanoutConcurrency = 4
|
|
|
|
// inboundApply is one inbound's share of a client op, ready to run.
|
|
type inboundApply struct {
|
|
id int
|
|
run func() (bool, error)
|
|
}
|
|
|
|
// fanoutInboundApplies runs the applies with the node pushes overlapping, so a
|
|
// client spanning several nodes no longer costs one RPC round-trip per node.
|
|
func fanoutInboundApplies(applies []inboundApply) (bool, error) {
|
|
var needRestart atomic.Bool
|
|
errs := make([]error, len(applies))
|
|
sem := make(chan struct{}, inboundFanoutConcurrency)
|
|
var wg sync.WaitGroup
|
|
for i := range applies {
|
|
wg.Add(1)
|
|
sem <- struct{}{}
|
|
go func() {
|
|
defer wg.Done()
|
|
defer func() { <-sem }()
|
|
// Off the request goroutine gin's Recovery no longer covers this,
|
|
// so an unrecovered panic here would take the whole panel down.
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
// The apply may already have committed, so ask for the
|
|
// restart the lost return value can no longer report.
|
|
needRestart.Store(true)
|
|
errs[i] = fmt.Errorf("inbound %d: panic: %v", applies[i].id, r)
|
|
logger.Errorf("panic applying client change to inbound %d: %v\n%s", applies[i].id, r, debug.Stack())
|
|
}
|
|
}()
|
|
nr, err := applies[i].run()
|
|
if nr {
|
|
needRestart.Store(true)
|
|
}
|
|
if err != nil {
|
|
errs[i] = fmt.Errorf("inbound %d: %w", applies[i].id, err)
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
return needRestart.Load(), errors.Join(errs...)
|
|
}
|
|
|
|
// fanoutInboundClientAdds applies one payload per inbound.
|
|
func (s *ClientService) fanoutInboundClientAdds(inboundSvc *InboundService, adds []*model.Inbound) (bool, error) {
|
|
applies := make([]inboundApply, 0, len(adds))
|
|
for _, add := range adds {
|
|
applies = append(applies, inboundApply{id: add.Id, run: func() (bool, error) {
|
|
return s.AddInboundClient(inboundSvc, add)
|
|
}})
|
|
}
|
|
return fanoutInboundApplies(applies)
|
|
}
|
|
|
|
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 err := validateClientTrafficReset(updated.TrafficReset, updated.TrafficResetDay); err != nil {
|
|
return false, err
|
|
}
|
|
normalizeClientTrafficReset(&updated)
|
|
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)
|
|
}
|
|
}
|
|
|
|
// Built before any inbound is written, as in Create: fillProtocolDefaults
|
|
// mints the shared credentials on the first inbound, later ones reuse them.
|
|
applies := make([]inboundApply, 0, len(inboundIds))
|
|
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 false, err
|
|
}
|
|
continue
|
|
}
|
|
return false, getErr
|
|
}
|
|
if existing.Email == "" {
|
|
continue
|
|
}
|
|
if err := s.fillProtocolDefaults(&updated, inbound); err != nil {
|
|
return false, err
|
|
}
|
|
clientForInbound := updated
|
|
if ips, ok := updated.AllowedIPsByInbound[ibId]; ok {
|
|
clientForInbound.AllowedIPs = ips
|
|
} else if !addressesFitAmneziaWGInbound(clientForInbound.AllowedIPs, inbound) {
|
|
// A single shared AllowedIPs field (the common case for a caller
|
|
// that never sends AllowedIPsByInbound) must never overwrite an
|
|
// inbound it doesn't belong to -- e.g. a client attached to both
|
|
// wg and awg saving its wg-labeled address would otherwise get
|
|
// that same address silently written into the awg peer config
|
|
// too. Clearing it here makes UpdateInboundClient's own
|
|
// empty-AllowedIPs carry-forward (see its WireGuard/AmneziaWG
|
|
// branch) preserve THIS inbound's existing, correct value
|
|
// instead.
|
|
clientForInbound.AllowedIPs = nil
|
|
}
|
|
settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {clientWithInboundFlow(clientForInbound, inbound)}})
|
|
if mErr != nil {
|
|
return false, mErr
|
|
}
|
|
data := &model.Inbound{Id: ibId, Settings: string(settingsPayload)}
|
|
applies = append(applies, inboundApply{id: ibId, run: func() (bool, error) {
|
|
return s.UpdateInboundClient(inboundSvc, data, existing.Email)
|
|
}})
|
|
}
|
|
needRestart, applyErr := fanoutInboundApplies(applies)
|
|
if applyErr != nil {
|
|
return needRestart, applyErr
|
|
}
|
|
|
|
// 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,
|
|
"traffic_reset": merged.TrafficReset,
|
|
"traffic_reset_day": merged.TrafficResetDay,
|
|
}).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
|
|
}
|
|
|
|
applies := make([]inboundApply, 0, len(inboundIds))
|
|
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
|
|
}
|
|
applies = append(applies, inboundApply{id: ibId, run: func() (bool, error) {
|
|
nr, delErr := s.DelInboundClientByEmail(inboundSvc, ibId, existing.Email, keepTraffic, true)
|
|
// The client is already absent from this inbound (data drift or a
|
|
// retried delete). Skip it — deletion stays idempotent.
|
|
if errors.Is(delErr, ErrClientNotInInbound) {
|
|
return nr, nil
|
|
}
|
|
return nr, delErr
|
|
}})
|
|
}
|
|
needRestart, applyErr := fanoutInboundApplies(applies)
|
|
if applyErr != nil {
|
|
delErrs = append(delErrs, applyErr)
|
|
}
|
|
// 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
|
|
}
|
|
|
|
// hasTunnelAttachment reports whether any of inboundIds is a currently
|
|
// existing WireGuard or AmneziaWG inbound. Inbounds that fail to load are
|
|
// skipped rather than treated as an error -- Attach's own loop already
|
|
// surfaces a real error for any inbound it can't load when it gets there.
|
|
func (s *ClientService) hasTunnelAttachment(inboundSvc *InboundService, inboundIds []int) bool {
|
|
for _, ibId := range inboundIds {
|
|
inbound, err := inboundSvc.GetInbound(ibId)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if inbound.Protocol == model.WireGuard || inbound.Protocol == model.AmneziaWG {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// addressesFitAmneziaWGInbound reports whether every entry in addrs falls
|
|
// inside ib's own configured subnet(s). AmneziaWG only: its kernel interface
|
|
// Address is exactly that subnet, so an address inherited from elsewhere (an
|
|
// identity attached to a WireGuard inbound first, say) produces a peer that
|
|
// can never connect -- Attach allocates fresh instead.
|
|
func addressesFitAmneziaWGInbound(addrs []string, ib *model.Inbound) bool {
|
|
if ib.Protocol != model.AmneziaWG || len(addrs) == 0 {
|
|
return true
|
|
}
|
|
v4Base, v6Base, err := defaultAmneziaWGSubnetBases(ib.Settings)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
bases := make([]netip.Prefix, 0, 2)
|
|
for _, base := range []string{v4Base, v6Base} {
|
|
if base == "" {
|
|
continue
|
|
}
|
|
prefix, pErr := netip.ParsePrefix(base)
|
|
if pErr != nil {
|
|
return false
|
|
}
|
|
bases = append(bases, prefix)
|
|
}
|
|
for _, a := range addrs {
|
|
host := wireguardHostAddr(a)
|
|
if !host.IsValid() {
|
|
return false
|
|
}
|
|
fits := false
|
|
for _, prefix := range bases {
|
|
if prefix.Contains(host) {
|
|
fits = true
|
|
break
|
|
}
|
|
}
|
|
if !fits {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// Attach applies the client to every requested inbound: one failing inbound no
|
|
// longer aborts the others, so the error can name several and needRestart holds.
|
|
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()
|
|
|
|
// If this identity has no CURRENT WireGuard/AmneziaWG attachment,
|
|
// clientWire.AllowedIPs (from the ClientRecord) is a leftover from
|
|
// whenever it last had one -- nothing reserves it anymore. Clear it so
|
|
// attaching to a tunnel inbound now allocates a fresh address instead
|
|
// of resurrecting the old one, which may no longer even be the lowest
|
|
// free slot. Left untouched when the identity already has an active
|
|
// tunnel elsewhere, so extending it to a second protocol still keeps
|
|
// the same address on both.
|
|
if !s.hasTunnelAttachment(inboundSvc, currentIds) {
|
|
clientWire.AllowedIPs = nil
|
|
}
|
|
|
|
adds := make([]*model.Inbound, 0, len(inboundIds))
|
|
for _, ibId := range inboundIds {
|
|
if _, attached := have[ibId]; attached {
|
|
continue
|
|
}
|
|
inbound, getErr := inboundSvc.GetInbound(ibId)
|
|
if getErr != nil {
|
|
return false, fmt.Errorf("inbound %d: %w", ibId, getErr)
|
|
}
|
|
copyClient := *clientWire
|
|
if !addressesFitAmneziaWGInbound(copyClient.AllowedIPs, inbound) {
|
|
copyClient.AllowedIPs = nil
|
|
}
|
|
if err := s.fillProtocolDefaults(©Client, inbound); err != nil {
|
|
return false, fmt.Errorf("inbound %d: %w", ibId, err)
|
|
}
|
|
settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {clientWithInboundFlow(copyClient, inbound)}})
|
|
if mErr != nil {
|
|
return false, fmt.Errorf("inbound %d: %w", ibId, mErr)
|
|
}
|
|
adds = append(adds, &model.Inbound{Id: ibId, Settings: string(settingsPayload)})
|
|
}
|
|
return s.fanoutInboundClientAdds(inboundSvc, adds)
|
|
}
|
|
|
|
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))
|
|
}
|
|
applies := make([]inboundApply, 0, len(inboundIds))
|
|
for _, ibId := range inboundIds {
|
|
applies = append(applies, inboundApply{id: ibId, run: func() (bool, error) {
|
|
nr, delErr := s.DelInboundClientByEmail(inboundSvc, ibId, email, keepTraffic, true)
|
|
if errors.Is(delErr, ErrClientNotInInbound) {
|
|
return nr, nil
|
|
}
|
|
return nr, delErr
|
|
}})
|
|
}
|
|
needRestart, delErr := fanoutInboundApplies(applies)
|
|
if delErr != nil {
|
|
return needRestart, delErr
|
|
}
|
|
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{}{}
|
|
}
|
|
|
|
applies := make([]inboundApply, 0, len(inboundIds))
|
|
for _, ibId := range inboundIds {
|
|
if _, attached := have[ibId]; !attached {
|
|
continue
|
|
}
|
|
if _, getErr := inboundSvc.GetInbound(ibId); getErr != nil {
|
|
return false, getErr
|
|
}
|
|
// Detach by email — the client's stable identity (see Delete).
|
|
if existing.Email == "" {
|
|
continue
|
|
}
|
|
applies = append(applies, inboundApply{id: ibId, run: func() (bool, error) {
|
|
nr, delErr := s.DelInboundClientByEmail(inboundSvc, ibId, existing.Email, true, false)
|
|
if errors.Is(delErr, ErrClientNotInInbound) {
|
|
return nr, nil
|
|
}
|
|
return nr, delErr
|
|
}})
|
|
}
|
|
return fanoutInboundApplies(applies)
|
|
}
|