| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166 |
- // 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
- }
|