mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-28 00:24:19 +00:00
2a7342baa9
* 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>
1167 lines
34 KiB
Go
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
|
|
}
|