Files
3x-ui/internal/web/service/inbound.go
T
iYuan 2a7342baa9 feat: add inbound share address strategy (#5162)
* feat: add inbound share address strategy

Allow node-managed inbounds to choose whether exported share links use the node address, routable listen address, or a custom endpoint. Preserve locally configured share address fields during remote node traffic sync.

Refs #5161

Refs #4891

* fix: preserve inbound share address settings

Forward share address fields to remote nodes, keep existing values when older update payloads omit them, align localhost handling between frontend and subscriptions, and preserve share address settings when cloning inbounds.

* fix: keep share address strategy out of subscriptions

Limit the new share address strategy to direct exported share links and QR codes. Restore subscription address resolution to the existing panel-owned behavior and update the UI help text accordingly.

* fix: address share address review feedback

* fix: validate custom share address

* fix

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-11 20:24:15 +02:00

1167 lines
34 KiB
Go

// Package service provides business logic services for the 3x-ui web panel,
// including inbound/outbound management, user administration, settings, and Xray integration.
package service
import (
"context"
"encoding/json"
"fmt"
"net"
"sort"
"strings"
"time"
"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/netsafe"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"gorm.io/gorm"
)
type InboundService struct {
xrayApi xray.XrayAPI
clientService ClientService
fallbackService FallbackService
}
func normalizeInboundShareAddrStrategy(strategy string) string {
strategy = strings.TrimSpace(strategy)
switch strategy {
case "listen", "custom":
return strategy
default:
return "node"
}
}
func normalizeInboundShareAddress(inbound *model.Inbound) {
if inbound == nil {
return
}
inbound.ShareAddrStrategy = normalizeInboundShareAddrStrategy(inbound.ShareAddrStrategy)
if addr, err := normalizeInboundShareHost(inbound.ShareAddr); err == nil {
inbound.ShareAddr = addr
} else {
inbound.ShareAddr = strings.TrimSpace(inbound.ShareAddr)
}
}
func normalizeInboundShareAddressStrict(inbound *model.Inbound) error {
if inbound == nil {
return nil
}
inbound.ShareAddrStrategy = normalizeInboundShareAddrStrategy(inbound.ShareAddrStrategy)
addr, err := normalizeInboundShareHost(inbound.ShareAddr)
if err != nil {
return common.NewError("shareAddr must be a host or IP without scheme or port")
}
inbound.ShareAddr = addr
return nil
}
func normalizeInboundShareHost(raw string) (string, error) {
addr := strings.TrimSpace(raw)
if addr == "" {
return "", nil
}
if strings.Contains(addr, "://") || strings.HasPrefix(addr, "//") || strings.ContainsAny(addr, "/?#@") {
return "", fmt.Errorf("invalid share address %q", raw)
}
if strings.HasPrefix(addr, "[") {
if !strings.HasSuffix(addr, "]") {
return "", fmt.Errorf("invalid IPv6 host %q", raw)
}
ip := net.ParseIP(addr[1 : len(addr)-1])
if ip == nil || ip.To4() != nil {
return "", fmt.Errorf("invalid IPv6 host %q", raw)
}
return "[" + ip.String() + "]", nil
}
if strings.Contains(addr, ":") {
if _, _, err := net.SplitHostPort(addr); err == nil {
return "", fmt.Errorf("share address must not include port")
}
ip := net.ParseIP(addr)
if ip == nil || ip.To4() != nil {
return "", fmt.Errorf("invalid IPv6 host %q", raw)
}
return "[" + ip.String() + "]", nil
}
host, err := netsafe.NormalizeHost(addr)
if err != nil {
return "", err
}
return host, nil
}
func normalizeInboundShareAddressColumns(tx *gorm.DB) error {
if tx == nil || !tx.Migrator().HasColumn(&model.Inbound{}, "share_addr_strategy") {
return nil
}
strategyExpr := `CASE TRIM(COALESCE(share_addr_strategy, '')) WHEN 'listen' THEN 'listen' WHEN 'custom' THEN 'custom' ELSE 'node' END`
if err := tx.Exec(`UPDATE inbounds SET share_addr_strategy = ` + strategyExpr + ` WHERE share_addr_strategy IS NULL OR share_addr_strategy <> ` + strategyExpr).Error; err != nil {
return err
}
hasShareAddr := tx.Migrator().HasColumn(&model.Inbound{}, "share_addr")
if hasShareAddr {
if err := tx.Exec(`UPDATE inbounds SET share_addr = TRIM(share_addr) WHERE share_addr IS NOT NULL AND share_addr <> TRIM(share_addr)`).Error; err != nil {
return err
}
}
if !hasShareAddr {
return nil
}
var rows []struct {
Id int
ShareAddrStrategy string
ShareAddr string
}
if err := tx.Model(&model.Inbound{}).Select("id", "share_addr_strategy", "share_addr").Find(&rows).Error; err != nil {
return err
}
for _, row := range rows {
strategy := normalizeInboundShareAddrStrategy(row.ShareAddrStrategy)
addr, addrErr := normalizeInboundShareHost(row.ShareAddr)
if addrErr != nil {
strategy = "node"
addr = ""
}
updates := map[string]any{}
if strategy != row.ShareAddrStrategy {
updates["share_addr_strategy"] = strategy
}
if addr != row.ShareAddr {
updates["share_addr"] = addr
}
if len(updates) > 0 {
if err := tx.Model(&model.Inbound{}).Where("id = ?", row.Id).Updates(updates).Error; err != nil {
return err
}
}
}
return nil
}
// GetInbounds retrieves all inbounds for a specific user with client stats.
func (s *InboundService) GetInbounds(userId int) ([]*model.Inbound, error) {
db := database.GetDB()
var inbounds []*model.Inbound
err := db.Model(model.Inbound{}).Preload("ClientStats").Where("user_id = ?", userId).Order("id ASC").Find(&inbounds).Error
if err != nil && err != gorm.ErrRecordNotFound {
return nil, err
}
s.enrichClientStats(db, inbounds)
s.annotateFallbackParents(db, inbounds)
s.annotateLocalOriginGuid(inbounds)
return inbounds, nil
}
// annotateLocalOriginGuid fills OriginNodeGuid for this panel's OWN inbounds
// (NodeID == nil) with the panel's stable GUID; inbounds synced from a node
// already carry the originating node's GUID. Read-time only (not persisted) so
// the per-inbound online view can scope by GUID uniformly across a chain of
// nodes (#4983).
func (s *InboundService) annotateLocalOriginGuid(inbounds []*model.Inbound) {
if len(inbounds) == 0 {
return
}
guid := s.panelGuid()
if guid == "" {
return
}
for _, ib := range inbounds {
if ib.OriginNodeGuid == "" && ib.NodeID == nil {
ib.OriginNodeGuid = guid
}
}
}
// GetInboundsSlim returns the same list of inbounds as GetInbounds but
// strips every per-client field other than email / enable / comment from
// settings.clients and skips UUID/SubId enrichment on ClientStats. The
// inbounds page only needs those three to roll up client counts and
// render badges, so this trims tens of bytes per client (UUID, password,
// flow, security, totalGB, expiryTime, limitIp, tgId, ...) which adds
// up fast on installs with thousands of clients.
//
// Full client data is still available through GET /panel/api/inbounds/get/:id
// for the edit/info/qr/export/clone flows that need it.
func (s *InboundService) GetInboundsSlim(userId int) ([]*model.Inbound, error) {
db := database.GetDB()
var inbounds []*model.Inbound
err := db.Model(model.Inbound{}).Preload("ClientStats").Where("user_id = ?", userId).Order("id ASC").Find(&inbounds).Error
if err != nil && err != gorm.ErrRecordNotFound {
return nil, err
}
s.annotateFallbackParents(db, inbounds)
s.annotateLocalOriginGuid(inbounds)
// Top up stats rows owned by sibling inbounds (multi-attached clients)
// so the list's depleted/expiring badges see every client; the UUID/SubId
// enrichment stays skipped. Must run before slimming strips the settings.
s.backfillClientStats(db, inbounds)
// Slim feeds the panel UI only (masters poll the full list), so the badge
// math may see the cross-panel totals a master pushed.
s.overlayInboundsClientStats(db, inbounds)
for _, ib := range inbounds {
ib.Settings = slimSettingsClients(ib.Settings)
}
return inbounds, nil
}
// slimSettingsClients rewrites the inbound settings JSON so settings.clients[]
// keeps only the fields the list view actually reads. Returns the input
// unchanged when the JSON can't be parsed or has no clients array.
func slimSettingsClients(settings string) string {
if settings == "" {
return settings
}
var raw map[string]any
if err := json.Unmarshal([]byte(settings), &raw); err != nil {
return settings
}
clients, ok := raw["clients"].([]any)
if !ok || len(clients) == 0 {
return settings
}
slim := make([]any, 0, len(clients))
for _, entry := range clients {
c, ok := entry.(map[string]any)
if !ok {
continue
}
row := make(map[string]any, 3)
if v, ok := c["email"]; ok {
row["email"] = v
}
if v, ok := c["enable"]; ok {
row["enable"] = v
}
if v, ok := c["comment"]; ok && v != "" {
row["comment"] = v
}
slim = append(slim, row)
}
raw["clients"] = slim
out, err := json.Marshal(raw)
if err != nil {
return settings
}
return string(out)
}
// annotateFallbackParents fills FallbackParent on each inbound that is
// the child side of a fallback rule. One DB round-trip serves the full
// list — the frontend needs this to rewrite the child's client-share
// link so it points at the master's reachable endpoint.
func (s *InboundService) annotateFallbackParents(db *gorm.DB, inbounds []*model.Inbound) {
if len(inbounds) == 0 {
return
}
childIds := make([]int, 0, len(inbounds))
for _, ib := range inbounds {
childIds = append(childIds, ib.Id)
}
var rows []model.InboundFallback
if err := db.Where("child_id IN ?", childIds).
Order("sort_order ASC, id ASC").
Find(&rows).Error; err != nil {
return
}
first := make(map[int]model.InboundFallback, len(rows))
for _, r := range rows {
if _, ok := first[r.ChildId]; !ok {
first[r.ChildId] = r
}
}
for _, ib := range inbounds {
if r, ok := first[ib.Id]; ok {
ib.FallbackParent = &model.FallbackParentInfo{
MasterId: r.MasterId,
Path: r.Path,
}
}
}
}
type InboundOption struct {
Id int `json:"id" example:"1"`
Remark string `json:"remark" example:"VLESS-443"`
Tag string `json:"tag" example:"in-443-tcp"`
Protocol string `json:"protocol" example:"vless"`
Port int `json:"port" example:"443"`
TlsFlowCapable bool `json:"tlsFlowCapable" example:"true"`
SsMethod string `json:"ssMethod"`
}
func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error) {
db := database.GetDB()
var rows []struct {
Id int `gorm:"column:id"`
Remark string `gorm:"column:remark"`
Tag string `gorm:"column:tag"`
Protocol string `gorm:"column:protocol"`
Port int `gorm:"column:port"`
StreamSettings string `gorm:"column:stream_settings"`
Settings string `gorm:"column:settings"`
}
err := db.Table("inbounds").
Select("id, remark, tag, protocol, port, stream_settings, settings").
Where("user_id = ?", userId).
Order("id ASC").
Scan(&rows).Error
if err != nil && err != gorm.ErrRecordNotFound {
return nil, err
}
out := make([]InboundOption, 0, len(rows))
for _, r := range rows {
out = append(out, InboundOption{
Id: r.Id,
Remark: r.Remark,
Tag: r.Tag,
Protocol: r.Protocol,
Port: r.Port,
TlsFlowCapable: inboundCanEnableTlsFlow(r.Protocol, r.StreamSettings, r.Settings),
SsMethod: inboundShadowsocksMethod(r.Protocol, r.Settings),
})
}
return out, nil
}
// GetAllInbounds retrieves all inbounds with client stats.
func (s *InboundService) GetAllInbounds() ([]*model.Inbound, error) {
db := database.GetDB()
var inbounds []*model.Inbound
err := db.Model(model.Inbound{}).Preload("ClientStats").Find(&inbounds).Error
if err != nil && err != gorm.ErrRecordNotFound {
return nil, err
}
s.enrichClientStats(db, inbounds)
return inbounds, nil
}
func (s *InboundService) GetInboundsByTrafficReset(period string) ([]*model.Inbound, error) {
db := database.GetDB()
var inbounds []*model.Inbound
err := db.Model(model.Inbound{}).Where("traffic_reset = ?", period).Find(&inbounds).Error
if err != nil && err != gorm.ErrRecordNotFound {
return nil, err
}
return inbounds, nil
}
func (s *InboundService) GetClients(inbound *model.Inbound) ([]model.Client, error) {
settings := map[string][]model.Client{}
json.Unmarshal([]byte(inbound.Settings), &settings)
if settings == nil {
return nil, fmt.Errorf("setting is null")
}
clients := settings["clients"]
if clients == nil {
return nil, nil
}
return clients, nil
}
func (s *InboundService) GetAllEmails() ([]string, error) {
db := database.GetDB()
var emails []string
query := fmt.Sprintf(
"SELECT DISTINCT %s %s",
database.JSONFieldText("client.value", "email"),
database.JSONClientsFromInbound(),
)
if err := db.Raw(query).Scan(&emails).Error; err != nil {
return nil, err
}
return emails, nil
}
// getAllEmailSubIDs returns email→subId. An email seen with two different
// non-empty subIds is locked (mapped to "") so neither identity can claim it.
func (s *InboundService) getAllEmailSubIDs() (map[string]string, error) {
db := database.GetDB()
var rows []struct {
Email string
SubID string
}
query := fmt.Sprintf(
"SELECT %s AS email, %s AS sub_id %s",
database.JSONFieldText("client.value", "email"),
database.JSONFieldText("client.value", "subId"),
database.JSONClientsFromInbound(),
)
if err := db.Raw(query).Scan(&rows).Error; err != nil {
return nil, err
}
result := make(map[string]string, len(rows))
for _, r := range rows {
email := strings.ToLower(r.Email)
if email == "" {
continue
}
subID := r.SubID
if existing, ok := result[email]; ok {
if existing != subID {
result[email] = ""
}
continue
}
result[email] = subID
}
return result, nil
}
// normalizeStreamSettings clears StreamSettings for protocols that don't use it.
// Only vmess, vless, trojan, shadowsocks, hysteria, and wireguard protocols use
// streamSettings (wireguard for finalmask UDP masks and sockopt on its listener).
func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) {
protocolsWithStream := map[model.Protocol]bool{
model.VMESS: true,
model.VLESS: true,
model.Trojan: true,
model.Shadowsocks: true,
model.Hysteria: true,
model.WireGuard: true,
}
if !protocolsWithStream[inbound.Protocol] {
inbound.StreamSettings = ""
}
}
// normalizeMtprotoSecret rebuilds an mtproto inbound's FakeTLS secret so it is
// always valid and matches the configured domain before the row is persisted.
func (s *InboundService) normalizeMtprotoSecret(inbound *model.Inbound) {
if inbound.Protocol != model.MTProto {
return
}
if healed, ok := model.HealMtprotoSecret(inbound.Settings); ok {
inbound.Settings = healed
}
}
// AddInbound creates a new inbound configuration.
// It validates port uniqueness, client email uniqueness, and required fields,
// then saves the inbound to the database and optionally adds it to the running Xray instance.
// Returns the created inbound, whether Xray needs restart, and any error.
func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
// Normalize streamSettings based on protocol
s.normalizeStreamSettings(inbound)
s.normalizeMtprotoSecret(inbound)
if err := normalizeInboundShareAddressStrict(inbound); err != nil {
return inbound, false, err
}
conflict, err := s.checkPortConflict(inbound, 0)
if err != nil {
return inbound, false, err
}
if conflict != nil {
return inbound, false, common.NewError(conflict.String())
}
inbound.Tag, err = s.resolveInboundTag(inbound, 0)
if err != nil {
return inbound, false, err
}
clients, err := s.GetClients(inbound)
if err != nil {
return inbound, false, err
}
existEmail, err := s.clientService.checkEmailsExistForClients(s, clients, nil)
if err != nil {
return inbound, false, err
}
if existEmail != "" {
return inbound, false, common.NewError("Duplicate email:", existEmail)
}
// Ensure created_at and updated_at on clients in settings
if len(clients) > 0 {
var settings map[string]any
if err2 := json.Unmarshal([]byte(inbound.Settings), &settings); err2 == nil && settings != nil {
now := time.Now().Unix() * 1000
updatedClients := make([]model.Client, 0, len(clients))
for _, c := range clients {
if c.CreatedAt == 0 {
c.CreatedAt = now
}
c.UpdatedAt = now
updatedClients = append(updatedClients, c)
}
settings["clients"] = updatedClients
if bs, err3 := json.MarshalIndent(settings, "", " "); err3 == nil {
inbound.Settings = string(bs)
} else {
logger.Debug("Unable to marshal inbound settings with timestamps:", err3)
}
} else if err2 != nil {
logger.Debug("Unable to parse inbound settings for timestamps:", err2)
}
}
// Secure client ID
for _, client := range clients {
switch inbound.Protocol {
case "trojan":
if client.Password == "" {
return inbound, false, common.NewError("empty client ID")
}
case "shadowsocks":
if client.Email == "" {
return inbound, false, common.NewError("empty client ID")
}
case "hysteria":
if client.Auth == "" {
return inbound, false, common.NewError("empty client ID")
}
default:
if client.ID == "" {
return inbound, false, common.NewError("empty client ID")
}
}
}
db := database.GetDB()
tx := db.Begin()
markDirty := false
defer func() {
if err != nil {
tx.Rollback()
return
}
tx.Commit()
if markDirty && inbound.NodeID != nil {
if dErr := (&NodeService{}).MarkNodeDirty(*inbound.NodeID); dErr != nil {
logger.Warning("mark node dirty failed:", dErr)
}
}
}()
err = tx.Save(inbound).Error
if err == nil {
if len(inbound.ClientStats) == 0 {
for _, client := range clients {
s.AddClientStat(tx, inbound.Id, &client)
}
}
} else {
return inbound, false, err
}
if err = s.clientService.SyncInbound(tx, inbound.Id, clients); err != nil {
return inbound, false, err
}
needRestart := false
if inbound.Enable {
rt, push, dirty, perr := s.nodePushPlan(inbound)
if perr != nil {
err = perr
return inbound, false, err
}
if dirty {
markDirty = true
}
if push {
if err1 := rt.AddInbound(context.Background(), inbound); err1 == nil {
logger.Debug("New inbound added on", rt.Name(), ":", inbound.Tag)
} else {
logger.Debug("Unable to add inbound on", rt.Name(), ":", err1)
if inbound.NodeID != nil {
markDirty = true
} else {
needRestart = true
}
}
}
}
return inbound, needRestart, err
}
func (s *InboundService) DelInbound(id int) (bool, error) {
db := database.GetDB()
needRestart := false
markDirty := false
var ib model.Inbound
loadErr := db.Model(model.Inbound{}).Where("id = ?", id).First(&ib).Error
if loadErr == nil {
shouldPushToRuntime := ib.NodeID != nil || ib.Enable
if shouldPushToRuntime {
rt, push, dirty, perr := s.nodePushPlan(&ib)
if perr != nil {
logger.Warning("DelInbound: node lookup failed, deleting central row anyway:", perr)
markDirty = true
} else if push {
if err1 := rt.DelInbound(context.Background(), &ib); err1 == nil {
logger.Debug("Inbound deleted on", rt.Name(), ":", ib.Tag)
} else {
logger.Warning("DelInbound on", rt.Name(), "failed, deleting central row anyway:", err1)
if ib.NodeID == nil {
needRestart = true
} else {
markDirty = true
}
}
} else if ib.NodeID == nil {
needRestart = true
} else if dirty {
markDirty = true
}
} else {
logger.Debug("DelInbound: skipping runtime push for disabled local inbound id:", id)
}
} else {
logger.Debug("DelInbound: inbound not found, id:", id)
}
if err := s.clientService.DetachInbound(db, id); err != nil {
return false, err
}
if err := db.Delete(model.Inbound{}, id).Error; err != nil {
return needRestart, err
}
if markDirty && ib.NodeID != nil {
if dErr := (&NodeService{}).MarkNodeDirty(*ib.NodeID); dErr != nil {
logger.Warning("mark node dirty failed:", dErr)
}
}
if !database.IsPostgres() {
var count int64
if err := db.Model(&model.Inbound{}).Count(&count).Error; err != nil {
return needRestart, err
}
if count == 0 {
if err := db.Exec("DELETE FROM sqlite_sequence WHERE name = ?", "inbounds").Error; err != nil {
return needRestart, err
}
}
}
return needRestart, nil
}
type BulkDelInboundResult struct {
Deleted int `json:"deleted"`
Skipped []BulkDelInboundReport `json:"skipped,omitempty"`
}
type BulkDelInboundReport struct {
Id int `json:"id"`
Reason string `json:"reason"`
}
// DelInbounds removes every inbound in the list, reusing the single-delete
// path per id. Failures are recorded in Skipped and processing continues for
// the rest; the aggregated needRestart is returned so the caller restarts
// xray at most once.
func (s *InboundService) DelInbounds(ids []int) (BulkDelInboundResult, bool, error) {
result := BulkDelInboundResult{}
needRestart := false
for _, id := range ids {
r, err := s.DelInbound(id)
if err != nil {
result.Skipped = append(result.Skipped, BulkDelInboundReport{Id: id, Reason: err.Error()})
continue
}
result.Deleted++
if r {
needRestart = true
}
}
return result, needRestart, nil
}
func (s *InboundService) GetInbound(id int) (*model.Inbound, error) {
db := database.GetDB()
inbound := &model.Inbound{}
err := db.Model(model.Inbound{}).First(inbound, id).Error
if err != nil {
return nil, err
}
return inbound, nil
}
func (s *InboundService) GetInboundDetail(id int) (*model.Inbound, error) {
db := database.GetDB()
inbound := &model.Inbound{}
err := db.Model(model.Inbound{}).Preload("ClientStats").First(inbound, id).Error
if err != nil {
return nil, err
}
s.enrichClientStats(db, []*model.Inbound{inbound})
s.overlayInboundsClientStats(db, []*model.Inbound{inbound})
return inbound, nil
}
func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
inbound, err := s.GetInbound(id)
if err != nil {
return false, err
}
if inbound.Enable == enable {
return false, nil
}
db := database.GetDB()
if err := db.Model(model.Inbound{}).Where("id = ?", id).
Update("enable", enable).Error; err != nil {
return false, err
}
inbound.Enable = enable
needRestart := false
rt, push, dirty, perr := s.nodePushPlan(inbound)
if perr != nil {
return false, perr
}
// Remote nodes interpret DelInbound as a real row delete (it hits
// panel/api/inbounds/del/:id on the remote), so toggling the enable
// switch on a remote inbound used to wipe the row entirely (#4402).
// PATCH the remote row via UpdateInbound instead — preserves the
// settings/client history and just flips the enable flag.
if inbound.NodeID != nil {
if push {
if err := rt.UpdateInbound(context.Background(), inbound, inbound); err != nil {
logger.Warning("SetInboundEnable: remote UpdateInbound on", rt.Name(), "failed:", err)
dirty = true
}
}
if dirty {
if dErr := (&NodeService{}).MarkNodeDirty(*inbound.NodeID); dErr != nil {
logger.Warning("mark node dirty failed:", dErr)
}
}
return false, nil
}
if !push {
return true, nil
}
if err := rt.DelInbound(context.Background(), inbound); err != nil &&
!strings.Contains(err.Error(), "not found") {
logger.Debug("SetInboundEnable: DelInbound on", rt.Name(), "failed:", err)
needRestart = true
}
if !enable {
return needRestart, nil
}
runtimeInbound, err := s.buildRuntimeInboundForAPI(db, inbound)
if err != nil {
logger.Debug("SetInboundEnable: build runtime config failed:", err)
return true, nil
}
if err := rt.AddInbound(context.Background(), runtimeInbound); err != nil {
logger.Debug("SetInboundEnable: AddInbound on", rt.Name(), "failed:", err)
needRestart = true
}
return needRestart, nil
}
func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
// Normalize streamSettings based on protocol
s.normalizeStreamSettings(inbound)
s.normalizeMtprotoSecret(inbound)
conflict, err := s.checkPortConflict(inbound, inbound.Id)
if err != nil {
return inbound, false, err
}
if conflict != nil {
return inbound, false, common.NewError(conflict.String())
}
oldInbound, err := s.GetInbound(inbound.Id)
if err != nil {
return inbound, false, err
}
inbound.NodeID = oldInbound.NodeID
tag := oldInbound.Tag
oldBits := inboundTransports(oldInbound.Protocol, oldInbound.StreamSettings, oldInbound.Settings)
oldTagWasAuto := isAutoGeneratedTag(tag, oldInbound.Port, oldInbound.NodeID, oldBits)
db := database.GetDB()
tx := db.Begin()
markDirty := false
defer func() {
if err != nil {
tx.Rollback()
return
}
tx.Commit()
if markDirty && oldInbound.NodeID != nil {
if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
logger.Warning("mark node dirty failed:", dErr)
}
}
}()
err = s.updateClientTraffics(tx, oldInbound, inbound)
if err != nil {
return inbound, false, err
}
// Ensure created_at and updated_at exist in inbound.Settings clients
{
var oldSettings map[string]any
_ = json.Unmarshal([]byte(oldInbound.Settings), &oldSettings)
emailToCreated := map[string]int64{}
emailToUpdated := map[string]int64{}
if oldSettings != nil {
if oc, ok := oldSettings["clients"].([]any); ok {
for _, it := range oc {
if m, ok2 := it.(map[string]any); ok2 {
if email, ok3 := m["email"].(string); ok3 {
switch v := m["created_at"].(type) {
case float64:
emailToCreated[email] = int64(v)
case int64:
emailToCreated[email] = v
}
switch v := m["updated_at"].(type) {
case float64:
emailToUpdated[email] = int64(v)
case int64:
emailToUpdated[email] = v
}
}
}
}
}
}
var newSettings map[string]any
if err2 := json.Unmarshal([]byte(inbound.Settings), &newSettings); err2 == nil && newSettings != nil {
now := time.Now().Unix() * 1000
if nSlice, ok := newSettings["clients"].([]any); ok {
for i := range nSlice {
if m, ok2 := nSlice[i].(map[string]any); ok2 {
email, _ := m["email"].(string)
if _, ok3 := m["created_at"]; !ok3 {
if v, ok4 := emailToCreated[email]; ok4 && v > 0 {
m["created_at"] = v
} else {
m["created_at"] = now
}
}
// Preserve client's updated_at if present; do not bump on parent inbound update
if _, hasUpdated := m["updated_at"]; !hasUpdated {
if v, ok4 := emailToUpdated[email]; ok4 && v > 0 {
m["updated_at"] = v
}
}
nSlice[i] = m
}
}
newSettings["clients"] = nSlice
if bs, err3 := json.MarshalIndent(newSettings, "", " "); err3 == nil {
inbound.Settings = string(bs)
}
}
}
}
oldInbound.Total = inbound.Total
oldInbound.Remark = inbound.Remark
oldInbound.Enable = inbound.Enable
oldInbound.ExpiryTime = inbound.ExpiryTime
oldInbound.TrafficReset = inbound.TrafficReset
oldInbound.Listen = inbound.Listen
oldInbound.Port = inbound.Port
oldInbound.Protocol = inbound.Protocol
oldInbound.Settings = inbound.Settings
oldInbound.StreamSettings = inbound.StreamSettings
oldInbound.Sniffing = inbound.Sniffing
if strings.TrimSpace(inbound.ShareAddrStrategy) == "" {
normalizeInboundShareAddress(oldInbound)
inbound.ShareAddrStrategy = oldInbound.ShareAddrStrategy
inbound.ShareAddr = oldInbound.ShareAddr
} else {
if err := normalizeInboundShareAddressStrict(inbound); err != nil {
return inbound, false, err
}
oldInbound.ShareAddrStrategy = inbound.ShareAddrStrategy
oldInbound.ShareAddr = inbound.ShareAddr
}
if oldTagWasAuto && inbound.Tag == tag {
inbound.Tag = ""
}
oldInbound.Tag, err = s.resolveInboundTag(inbound, inbound.Id)
if err != nil {
return inbound, false, err
}
inbound.Tag = oldInbound.Tag
needRestart := false
rt, push, dirty, perr := s.nodePushPlan(oldInbound)
if perr != nil {
err = perr
return inbound, false, err
}
if dirty {
markDirty = true
}
if oldInbound.NodeID == nil {
if !push {
needRestart = true
} else {
oldSnapshot := *oldInbound
oldSnapshot.Tag = tag
if err2 := rt.DelInbound(context.Background(), &oldSnapshot); err2 == nil {
logger.Debug("Old inbound deleted on", rt.Name(), ":", tag)
}
if inbound.Enable {
runtimeInbound, err2 := s.buildRuntimeInboundForAPI(tx, oldInbound)
if err2 != nil {
logger.Debug("Unable to prepare runtime inbound config:", err2)
needRestart = true
} else if err2 := rt.AddInbound(context.Background(), runtimeInbound); err2 == nil {
logger.Debug("Updated inbound added on", rt.Name(), ":", oldInbound.Tag)
} else {
logger.Debug("Unable to update inbound on", rt.Name(), ":", err2)
needRestart = true
}
}
}
} else if push {
oldSnapshot := *oldInbound
oldSnapshot.Tag = tag
if !inbound.Enable {
if err2 := rt.DelInbound(context.Background(), &oldSnapshot); err2 != nil {
logger.Warning("Unable to disable inbound on", rt.Name(), ":", err2)
markDirty = true
}
} else if err2 := rt.UpdateInbound(context.Background(), &oldSnapshot, oldInbound); err2 != nil {
logger.Warning("Unable to update inbound on", rt.Name(), ":", err2)
markDirty = true
}
}
if err = tx.Save(oldInbound).Error; err != nil {
return inbound, false, err
}
newClients, gcErr := s.GetClients(oldInbound)
if gcErr != nil {
err = gcErr
return inbound, false, err
}
if err = s.clientService.SyncInbound(tx, oldInbound.Id, newClients); err != nil {
return inbound, false, err
}
return inbound, needRestart, nil
}
func (s *InboundService) buildRuntimeInboundForAPI(tx *gorm.DB, inbound *model.Inbound) (*model.Inbound, error) {
if inbound == nil {
return nil, fmt.Errorf("inbound is nil")
}
runtimeInbound := *inbound
settings := map[string]any{}
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
return nil, err
}
clients, ok := settings["clients"].([]any)
if !ok {
return &runtimeInbound, nil
}
var clientStats []xray.ClientTraffic
err := tx.Model(xray.ClientTraffic{}).
Where("inbound_id = ?", inbound.Id).
Select("email", "enable").
Find(&clientStats).Error
if err != nil {
return nil, err
}
enableMap := make(map[string]bool, len(clientStats))
for _, clientTraffic := range clientStats {
enableMap[clientTraffic.Email] = clientTraffic.Enable
}
finalClients := make([]any, 0, len(clients))
for _, client := range clients {
c, ok := client.(map[string]any)
if !ok {
continue
}
email, _ := c["email"].(string)
if enable, exists := enableMap[email]; exists && !enable {
continue
}
if manualEnable, ok := c["enable"].(bool); ok && !manualEnable {
continue
}
finalClients = append(finalClients, c)
}
settings["clients"] = finalClients
modifiedSettings, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return nil, err
}
runtimeInbound.Settings = string(modifiedSettings)
return &runtimeInbound, nil
}
// updateClientTraffics syncs the ClientTraffic rows with the inbound's clients
// list: removes rows for emails that disappeared, inserts rows for newly-added
// emails. Uses sets for O(N) lookup — the previous nested-loop implementation
// was O(N²) and degraded into multi-second pauses on inbounds with thousands
// of clients (toggling, saving, or deleting any such inbound felt frozen).
func (s *InboundService) updateClientTraffics(tx *gorm.DB, oldInbound *model.Inbound, newInbound *model.Inbound) error {
oldClients, err := s.GetClients(oldInbound)
if err != nil {
return err
}
newClients, err := s.GetClients(newInbound)
if err != nil {
return err
}
// Email is the unique key for ClientTraffic rows. Clients without an
// email have no stats row to sync — skip them on both sides instead of
// risking a unique-constraint hit or accidental delete of an unrelated row.
oldEmails := make(map[string]struct{}, len(oldClients))
for i := range oldClients {
if oldClients[i].Email == "" {
continue
}
oldEmails[oldClients[i].Email] = struct{}{}
}
newEmails := make(map[string]struct{}, len(newClients))
for i := range newClients {
if newClients[i].Email == "" {
continue
}
newEmails[newClients[i].Email] = struct{}{}
}
// Drop stats rows for removed emails — but not when a sibling inbound
// still references the email, since the row is the shared accumulator.
for i := range oldClients {
email := oldClients[i].Email
if email == "" {
continue
}
if _, kept := newEmails[email]; kept {
continue
}
stillUsed, err := s.emailUsedByOtherInbounds(email, oldInbound.Id)
if err != nil {
return err
}
if stillUsed {
continue
}
if err := s.DelClientStat(tx, email); err != nil {
return err
}
// Keep inbound_client_ips in sync when the inbound edit drops an
// email, so the IP-limit job doesn't keep a ghost tracking row (#4963).
if err := s.DelClientIPs(tx, email); err != nil {
return err
}
}
for i := range newClients {
email := newClients[i].Email
if email == "" {
continue
}
if _, existed := oldEmails[email]; existed {
if err := s.UpdateClientStat(tx, email, &newClients[i]); err != nil {
return err
}
continue
}
if err := s.AddClientStat(tx, oldInbound.Id, &newClients[i]); err != nil {
return err
}
}
return nil
}
func (s *InboundService) GetInboundTags() (string, error) {
db := database.GetDB()
var inboundTags []string
err := db.Model(model.Inbound{}).Select("tag").Find(&inboundTags).Error
if err != nil && err != gorm.ErrRecordNotFound {
return "", err
}
tags, _ := json.Marshal(inboundTags)
return string(tags), nil
}
func (s *InboundService) GetClientReverseTags() (string, error) {
db := database.GetDB()
var inbounds []model.Inbound
err := db.Model(model.Inbound{}).Select("settings").Where("protocol = ?", "vless").Find(&inbounds).Error
if err != nil && err != gorm.ErrRecordNotFound {
return "[]", err
}
tagSet := make(map[string]struct{})
for _, inbound := range inbounds {
var settings map[string]any
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
continue
}
clients, ok := settings["clients"].([]any)
if !ok {
continue
}
for _, client := range clients {
clientMap, ok := client.(map[string]any)
if !ok {
continue
}
reverse, ok := clientMap["reverse"].(map[string]any)
if !ok {
continue
}
tag, _ := reverse["tag"].(string)
tag = strings.TrimSpace(tag)
if tag != "" {
tagSet[tag] = struct{}{}
}
}
}
rawTags := make([]string, 0, len(tagSet))
for tag := range tagSet {
rawTags = append(rawTags, tag)
}
sort.Strings(rawTags)
result, _ := json.Marshal(rawTags)
return string(result), nil
}
func (s *InboundService) SearchInbounds(query string) ([]*model.Inbound, error) {
db := database.GetDB()
var inbounds []*model.Inbound
err := db.Model(model.Inbound{}).Preload("ClientStats").Where("remark like ?", "%"+query+"%").Find(&inbounds).Error
if err != nil && err != gorm.ErrRecordNotFound {
return nil, err
}
return inbounds, nil
}