Files
3x-ui/internal/web/service/client_crud.go
T
YoungReckless4 56ac8d0fc7 feat(amneziawg): add opt-in Linux kernel datapath (#6877)
* feat(amneziawg): add opt-in Linux kernel datapath

Add the kernel backend as an explicit per-inbound option while keeping the embedded backend as the default. Include host preflight, UI/API support, the bundled installer CLI, lifecycle and accounting integration, regression tests, and upstream-facing documentation. Kernel mode is NAT-only and rejects unsupported routing, IPv6, forwarding, and pinned-listen configurations. Local saves check the local host; remote nodes check their own host during apply.

Preserve adopted interface accounting across panel restarts, retain manager state after failed teardown so the job can retry, prevent embedded startup while a kernel interface remains live, and prune collector state for removed peers. Tested the restart accounting path on Debian 13 with XanMod 7.1.11 on x86_64; Docker and cross-kernel coverage are not claimed.

* fix(amneziawg): align readiness and endpoint reporting

Expire endpoint sightings using the latest handshake and sweep stale addresses for the polled interface. Keep the readiness API schema, generated client types, and ready-host empty blocker list consistent; watch the nested form field and defer local readiness checks for remote targets. Document safe rollback before using a build without kernel support.

Regression evidence: the idle endpoint test failed before the collector change because the old address remained visible after 31 minutes; endpoint tests pass after the fix. The form component suite passes 3 tests; restoring the invalid array watch made its off-mode severity test fail, while the corrected dot-path watch passes. Targeted Go regressions across amneziawgkern, job, and service pass.

* style(amneziawg): format readiness test

* test: reproduce kernel AWG client validation gaps

* test(service): cover kernel AWG shared client fanout

* fix(amneziawg): validate kernel client fanout and config values

* test(amneziawgkern): cover owned interface lifecycle recovery

* fix(amneziawgkern): recover owned interfaces on restart

* test(amneziawgkern): retain visibility after recovered cleanup failure

* fix(amneziawgkern): report recovered interfaces while cleanup retries

* style(amneziawgkern): satisfy static analysis

* test(amneziawgkern): cover long rendered peer comments

* fix(amneziawgkern): accept long generated config comments

* test(amneziawgkern): refuse foreign occupied interfaces

Capture the current Ensure ordering failure with real temporary config paths and manager side-effect boundaries.

* test(amneziawgkern): cover stale interface collisions

Extend the Ensure regression across absent links, mismatched live interfaces, cached manager state, and Remove teardown.

* fix(amneziawgkern): verify interface ownership before mutation

Refuse to replace or tear down links unless the canonical config is a valid panel-owned file and its key matches the live interface. Preserve normal adoption and key rotation, and document migration of legacy unmarked configs.

Validation: go test ./internal/amneziawgkern -count=1; go test -race ./internal/amneziawgkern -count=1; golangci-lint run ./internal/amneziawgkern/...; git diff --check.

* build: update Go toolchain to 1.27.3

* test(amneziawg): reproduce unsafe key rotation recovery

Keep the previous owned config authoritative until teardown succeeds, and require fresh managers to rebuild structural key changes. These regressions expose the stuck live-key/disk-key mismatch after teardown failure.

* fix(amneziawg): recover interrupted key rotations

Keep the old owned config on disk until a structural rebuild has confirmed the previous interface is down. Fresh managers only adopt when the live config and desired structural identity match, so retries and removals retain the key needed to prove ownership.

* fix(scanner): align Go image with module version

* fix(docs): pin pnpm release with bootstrap fix

pnpm 12.9.1 restores a Node launcher before its postinstall replaces the target with ELF; pnpm 11 then runs that binary as JavaScript. Pin 12.10.0, which includes the shim fix, so the docs CI bootstrap can switch versions safely.

* fix(xray): build core from pinned module

The configured Xray release asset tag is unpublished, and the latest published binary lacks GetUsersStats used by the panel. Build the packaged core from the exact Xray module version already selected in go.mod for Docker and release targets.

* fix(tests): stabilize local network fixtures

Keep the TUIC stream fragmentation check on a working UDP loopback path,
allocate the AmneziaWG fixture ports dynamically, and wait for a real
Xray stats response before starting API mutation assertions. Recreate
failed readiness channels so an early connection cannot stall the probe.

Verified host and offline parallel Go suites, repeated occupied-port and
UDP tests, race/shuffle runs, and golangci-lint. Production behavior and
dependency versions are unchanged.

* fix(i18n): add the kernel-mode AmneziaWG keys to sk-SK

main gained the sk-SK locale after this branch added its kernel-mode keys
to the other 13, so i18n-dead-keys.test.ts failed on the merged tree.

* refactor(amneziawgkern): apply kernel interfaces with direct commands, no awg-quick

awg-quick evaluates PostUp/PostDown through a root shell, so every value the
panel rendered into its config was one validation slip away from root command
execution. The interface is now built with direct ip/awg/nft execs from a
setconf-only file: link add, awg setconf, address, mtu, an atomically replaced
nftables table per inbound, and the IPv4 forwarding sysctl written directly.

- Panel-owned names and paths: xui-awg<id> interfaces, xui_awg<id> NAT tables
  and configs under the panel's data dir, so nothing collides with an
  operator's own awg0/awg1 awg-quick tunnels in /etc/amnezia/amneziawg.
- FORWARD accepts (comment 3x-ui:xui-awg<id>) when iptables is present: Docker
  and ufw set the FORWARD policy to DROP, which silently black-holed the tunnel.
- A full panel stop records the final counters and tears the interfaces down,
  like the embedded datapath; a stopped panel enforces no quota or expiry.
  Crash survivors are still adopted without a rebuild.
- The job reads counters before reconciling, and from every managed interface,
  so a client disabled for quota, or a removed inbound, is charged for its last
  interval instead of losing it with the dropped peer.
- An unchanged reconcile tick forks no process (it probed the live key with
  `awg show` every 10s); readiness no longer loads the module from a GET.

Verified on the real module (WSL2 6.6 kernel, v3.1.20260906): create, peer and
egress changes in place, SIGKILL adoption with the same ifindex, SIGTERM
teardown leaving no link, table, FORWARD rule or config behind.

* fix(amneziawg): gate kernel host readiness only when a save starts the datapath

Every save of a kernel-mode inbound re-ran the host preflight, so a host that
lost its module (a kernel upgrade without a DKMS rebuild) refused even
disabling the inbound or switching it back to embedded mode. Host blockers now
apply only when the save starts the kernel datapath: a new inbound, a switch
from embedded, or enabling a disabled one. Feature blockers (IPv6, forwarded
ports, pinned listen) still apply to every save.

* feat(nodes): refuse kernel-mode AmneziaWG pushes to nodes that predate it

A node built before kernel mode drops the unknown kernelMode field and serves
the inbound on the embedded datapath while the master's form shows kernel
mode on. Nodes now advertise awg-kernel in X-3x-Node-Caps, and Remote refuses
to push a kernel-mode inbound to a node that has not advertised it, learning
the caps with an inbound list first when this Remote has not heard from it.

* fix(amneziawg): pin the kernel module installer to verified commits

The installer ran as root and built whatever upstream tag sorted newest at
install time. It now clones fixed tags (module v3.1.20260906, tools
v3.1.20260812) and refuses unless each resolves to its recorded commit. The
udp_tunnel perl patch is gone: those releases carry the kernel 7.1.5 compat
upstream, so the patch never matched.

* fix(i18n): translate the kernel-mode AmneziaWG strings

All thirteen non-English locales carried the English text verbatim.

* fix(amneziawg): skip the kernel traffic flush when no kernel interface runs

Shutdown listed the desired AmneziaWG inbounds from the database even on a
panel with no kernel interface, an unneeded query on every stop. A server
stopped before its database was opened panicked on it, which turned
TestServerStopWhenXrayNotRunningSucceeds red whenever it ran first.

---------

Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
2026-10-25 13:24:50 +01:00

1323 lines
43 KiB
Go

package service
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/netip"
"runtime/debug"
"slices"
"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/tuic"
"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
}
func validateClientRenewal(client model.Client) error {
if err := validateClientResetDay(client.ResetDay); err != nil {
return err
}
if client.ResetWeekday < 0 || client.ResetWeekday > 7 {
return common.NewError("client resetWeekday must be between 0 and 7, got:", client.ResetWeekday)
}
if client.ResetWeekday > 0 && (client.Reset > 0 || client.ResetDay > 0) {
return common.NewError("client weekly renewal cannot be combined with reset or resetDay")
}
return nil
}
func validateClientsRenewal(clients []model.Client) error {
for _, client := range clients {
if err := validateClientRenewal(client); err != nil {
return err
}
}
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
}
func clientResetCycles(tx *gorm.DB) *gorm.DB {
return tx.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")
}
// 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 := clientResetCycles(database.GetDB()).
Where("c.traffic_reset = ?", period).
Scan(&cycles).Error
if err != nil {
return nil, err
}
return cycles, nil
}
// quotaDisabledEmails lists the disabled clients whose usage reached their quota,
// only those in within unless it is nil. Read it before zeroing the counters,
// which hides why a client is off.
func quotaDisabledEmails(tx *gorm.DB, within []string) ([]string, error) {
var cycles []ClientResetCycle
if err := clientResetCycles(tx).Where("c.enable = ?", false).Scan(&cycles).Error; err != nil {
return nil, err
}
var in map[string]bool
if within != nil {
in = make(map[string]bool, len(within))
for _, e := range within {
in[e] = true
}
}
var emails []string
for _, c := range cycles {
if c.Depleted() && (in == nil || in[c.Email]) {
emails = append(emails, c.Email)
}
}
return emails, 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 := validateClientRenewal(client); 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)
client.ClientHostRuleIds = model.NormalizeClientHostRuleIds(client.ClientHostRuleIds)
if len(payload.InboundIds) == 0 {
return false, common.NewError("at least one inbound is required")
}
if client.SubID == "" {
client.SubID = uuid.NewString()
}
// Enable: omit defaults true via ClientCreatePayload.UnmarshalJSON; explicit false kept.
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
}
if err := validateAmneziaWGClientWrite(inbound, []model.Client{clientForInbound}); err != nil {
return false, fmt.Errorf("inbound %d: %w", ibId, err)
}
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
}
// nil means omitted and preserves the stored binding.
if client.ClientHostRuleIds != nil {
if err := database.GetDB().Model(&model.ClientRecord{}).
Where("email = ?", client.Email).
Update("client_host_rule_ids", model.EncodeClientHostRuleIds(client.ClientHostRuleIds)).Error; err != nil {
return needRestart, err
}
}
return needRestart, s.setClientLimitHwidByEmail(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)
}
// fanoutInboundResults runs one job per inbound with the node pushes
// overlapping, so a bulk op costs one RPC round-trip instead of one per node.
// limit is the caller's own cap: an op that allocates tunnel addresses passes 1,
// because allocation reads a cross-inbound used-set before it writes.
func fanoutInboundResults[T any](inboundIds []int, limit int, run func(i int) T) ([]T, []error) {
if limit < 1 {
limit = 1
}
out := make([]T, len(inboundIds))
errs := make([]error, len(inboundIds))
sem := make(chan struct{}, limit)
var wg sync.WaitGroup
for i := range inboundIds {
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 {
errs[i] = fmt.Errorf("inbound %d: panic: %v", inboundIds[i], r)
logger.Errorf("panic applying bulk client change to inbound %d: %v\n%s", inboundIds[i], r, debug.Stack())
}
}()
out[i] = run(i)
}()
}
wg.Wait()
return out, errs
}
// addFanoutLimit serializes an add that touches a tunnel inbound. WireGuard and
// AmneziaWG pick a free peer address by reading every inbound's used-set first,
// so two overlapping allocations hand out the same one and the second is refused.
func addFanoutLimit(anyTunnel bool) int {
if anyTunnel {
return 1
}
return inboundFanoutConcurrency
}
// sortedInboundIds gives the fanout a stable order, so which inbound wins a
// per-email report no longer depends on Go's map iteration order.
func sortedInboundIds[V any](byInbound map[int]V) []int {
ids := make([]int, 0, len(byInbound))
for id := range byInbound {
ids = append(ids, id)
}
slices.Sort(ids)
return ids
}
// markInboundNodesDirty makes a half-applied client edit unobservable to a node
// snapshot merge, which skips a node whose config is already flagged dirty.
func markInboundNodesDirty(inboundIds []int) error {
if len(inboundIds) == 0 {
return nil
}
var nodeIDs []int
for _, batch := range chunkInts(inboundIds, sqlInChunk) {
var ids []int
if err := database.GetDB().Model(&model.Inbound{}).
Where("id IN ? AND node_id IS NOT NULL", batch).
Distinct().Pluck("node_id", &ids).Error; err != nil {
return err
}
nodeIDs = append(nodeIDs, ids...)
}
if len(nodeIDs) == 0 {
return nil
}
return runSerializedTx(func(tx *gorm.DB) error {
svc := &NodeService{}
for _, id := range nodeIDs {
if err := svc.MarkNodeDirtyTx(tx, id); err != nil {
return err
}
}
return nil
})
}
func (s *ClientService) fillProtocolDefaults(c *model.Client, ib *model.Inbound) error {
return s.fillProtocolDefaultsAgainstExisting(c, ib, nil)
}
func (s *ClientService) fillProtocolDefaultsAgainstExisting(c *model.Client, ib *model.Inbound, existing []model.Client, identityEmail ...string) error {
if err := validateClientKeepAliveAgainstExisting(ib.Protocol, *c, existing, identityEmail...); err != nil {
return err
}
switch ib.Protocol {
case model.VMESS, model.VLESS:
if c.ID == "" {
c.ID = uuid.NewString()
}
case model.Trojan, model.MASQUE:
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))
}
case model.TUIC:
if err := tuic.ValidateRelayEmail(c.Email); err != nil {
return err
}
if c.ID == "" {
c.ID = uuid.NewString()
}
if c.Password == "" {
c.Password = strings.ReplaceAll(uuid.NewString(), "-", "")
}
}
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
}
// validateShadowsocks2022Inbound refuses a method or server key xray-core cannot
// build an inbound from: the core then does not start at all.
func validateShadowsocks2022Inbound(protocol model.Protocol, settings string) error {
if protocol != model.Shadowsocks {
return nil
}
var parsed struct {
Method string `json:"method"`
Password string `json:"password"`
}
if json.Unmarshal([]byte(settings), &parsed) != nil {
return nil
}
n := xray.Shadowsocks2022KeyBytes(parsed.Method)
if n == 0 {
return nil
}
if xray.Shadowsocks2022NoMultiUser(parsed.Method) {
return common.NewErrorf("shadowsocks inbound method %s does not support multiple users, use a 2022-blake3-aes method", parsed.Method)
}
if !xray.ValidShadowsocks2022Key(parsed.Method, parsed.Password) {
return common.NewErrorf("shadowsocks inbound password: %s needs a %d-byte key, base64 encoded", parsed.Method, n)
}
return nil
}
// 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
}
// The rename rewrites the one shared client record, so every node holding
// this client goes stale — not just the ones an inboundIds filter applies.
attachedIds := append([]int(nil), inboundIds...)
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 emailErr := tuic.ValidateRelayEmail(updated.Email); emailErr != nil {
var attached int64
if err := database.GetDB().Model(&model.Inbound{}).
Joins("JOIN client_inbounds ci ON ci.inbound_id = inbounds.id").
Where("ci.client_id = ? AND inbounds.protocol = ?", id, model.TUIC).Count(&attached).Error; err != nil {
return false, err
}
if attached > 0 {
return false, emailErr
}
}
if err := validateClientSubID(updated.SubID); err != nil {
return false, err
}
if err := validateClientRenewal(updated); 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
}
existingClientsByInbound := make(map[int][]model.Client, len(attachedIds))
if updated.KeepAlive != nil {
for _, inboundID := range attachedIds {
inbound, getErr := inboundSvc.GetInbound(inboundID)
if getErr != nil {
return false, fmt.Errorf("inbound %d: %w", inboundID, getErr)
}
clients, getErr := inboundSvc.GetClients(inbound)
if getErr != nil {
return false, fmt.Errorf("inbound %d: %w", inboundID, getErr)
}
existingClientsByInbound[inboundID] = clients
if err := validateClientKeepAliveAgainstExisting(inbound.Protocol, updated, clients, existing.Email); err != nil {
return false, fmt.Errorf("inbound %d: %w", inboundID, err)
}
}
}
normalizeClientTrafficReset(&updated)
updated.ClientHostRuleIds = model.NormalizeClientHostRuleIds(updated.ClientHostRuleIds)
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)
}
}
tunnelCount, tcErr := tunnelInboundCount(inboundIds)
if tcErr != nil {
return false, tcErr
}
// 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.fillProtocolDefaultsAgainstExisting(&updated, inbound, existingClientsByInbound[ibId], existing.Email); err != nil {
return false, err
}
clientForInbound := updated
if ips, ok := updated.AllowedIPsByInbound[ibId]; ok {
clientForInbound.AllowedIPs = ips
} else if tunnelCount > 1 && (inbound.Protocol == model.WireGuard || inbound.Protocol == model.AmneziaWG) {
// One shared peer field set cannot describe several peers: broadcast
// it and they all end up with the same keys and tunnel address.
clientForInbound.AllowedIPs = nil
clientForInbound.PrivateKey = ""
clientForInbound.PublicKey = ""
clientForInbound.PreSharedKey = ""
} 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
}
if err := validateAmneziaWGClientWrite(inbound, []model.Client{clientForInbound}); err != nil {
return false, fmt.Errorf("inbound %d: %w", ibId, err)
}
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)
}})
}
// Each apply marks only its OWN node dirty, so between the first and last
// one a merge could resurrect the pre-edit email as a second client.
if err := markInboundNodesDirty(attachedIds); err != nil {
return false, err
}
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())
keepAliveLegacy, keepAliveRange, keepAliveErr := model.KeepAliveStorage(merged.KeepAlive)
if keepAliveErr != nil {
return needRestart, keepAliveErr
}
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,
"client_host_rule_ids": model.EncodeClientHostRuleIds(merged.ClientHostRuleIds),
"wg_private_key": merged.PrivateKey,
"wg_public_key": merged.PublicKey,
"wg_allowed_ips": merged.AllowedIPs,
"wg_pre_shared_key": merged.PreSharedKey,
"wg_keep_alive": keepAliveLegacy,
"wg_keep_alive_range": keepAliveRange,
"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_weekday": merged.ResetWeekday,
"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
}
// nil preserves the stored binding, empty clears it.
if updated.ClientHostRuleIds != nil {
if err := database.GetDB().Model(&model.ClientRecord{}).
Where("id = ?", id).
Update("client_host_rule_ids", model.EncodeClientHostRuleIds(updated.ClientHostRuleIds)).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(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...)
}
if err := runSerializedTx(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
}
// tunnelInboundCount reports how many of inboundIds are WireGuard/AmneziaWG,
// i.e. how many independent peers one shared field set would be written to.
func tunnelInboundCount(inboundIds []int) (int64, error) {
if len(inboundIds) == 0 {
return 0, nil
}
var n int64
err := database.GetDB().Model(&model.Inbound{}).
Where("id IN ? AND protocol IN ?", inboundIds, []model.Protocol{model.WireGuard, model.AmneziaWG}).
Count(&n).Error
return n, err
}
// 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(&copyClient, 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)
}