mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-12 15:46:06 +00:00
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>
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"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"
|
||||
@@ -25,6 +27,125 @@ type InboundService struct {
|
||||
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()
|
||||
@@ -332,6 +453,9 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
|
||||
// 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 {
|
||||
@@ -760,6 +884,17 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||
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 = ""
|
||||
}
|
||||
|
||||
@@ -52,6 +52,9 @@ func (s *InboundService) MigrationRequirements() {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err = normalizeInboundShareAddressColumns(tx); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Normalize "enable" columns to boolean on Postgres. Legacy SQLite data
|
||||
// (0/1 integers), partial migrations, or mixed write paths (public API
|
||||
|
||||
@@ -89,3 +89,90 @@ func TestMigrationRequirements_BackfillsClientTrafficsWithMultiDomainInbound(t *
|
||||
t.Errorf("MultiDomain migration did not commit; streamSettings = %q", refreshed.StreamSettings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationRequirements_NormalizesShareAddressFields(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
db := database.GetDB()
|
||||
|
||||
invalidStrategy := &model.Inbound{
|
||||
UserId: 1,
|
||||
Tag: "invalid-share-strategy",
|
||||
Enable: true,
|
||||
Port: 31001,
|
||||
Protocol: model.VLESS,
|
||||
Settings: `{"clients":[]}`,
|
||||
StreamSettings: `{"network":"tcp","security":"none"}`,
|
||||
}
|
||||
paddedStrategy := &model.Inbound{
|
||||
UserId: 1,
|
||||
Tag: "padded-share-strategy",
|
||||
Enable: true,
|
||||
Port: 31002,
|
||||
Protocol: model.VLESS,
|
||||
Settings: `{"clients":[]}`,
|
||||
StreamSettings: `{"network":"tcp","security":"none"}`,
|
||||
}
|
||||
invalidAddress := &model.Inbound{
|
||||
UserId: 1,
|
||||
Tag: "invalid-share-address",
|
||||
Enable: true,
|
||||
Port: 31003,
|
||||
Protocol: model.VLESS,
|
||||
Settings: `{"clients":[]}`,
|
||||
StreamSettings: `{"network":"tcp","security":"none"}`,
|
||||
}
|
||||
if err := db.Create(invalidStrategy).Error; err != nil {
|
||||
t.Fatalf("create invalid strategy inbound: %v", err)
|
||||
}
|
||||
if err := db.Create(paddedStrategy).Error; err != nil {
|
||||
t.Fatalf("create padded strategy inbound: %v", err)
|
||||
}
|
||||
if err := db.Create(invalidAddress).Error; err != nil {
|
||||
t.Fatalf("create invalid address inbound: %v", err)
|
||||
}
|
||||
if err := db.Model(&model.Inbound{}).Where("id = ?", invalidStrategy.Id).Updates(map[string]any{
|
||||
"share_addr_strategy": " auto ",
|
||||
"share_addr": " edge.example.com ",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed invalid share fields: %v", err)
|
||||
}
|
||||
if err := db.Model(&model.Inbound{}).Where("id = ?", paddedStrategy.Id).Updates(map[string]any{
|
||||
"share_addr_strategy": " listen ",
|
||||
"share_addr": " 10.0.0.1 ",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed padded share fields: %v", err)
|
||||
}
|
||||
if err := db.Model(&model.Inbound{}).Where("id = ?", invalidAddress.Id).Updates(map[string]any{
|
||||
"share_addr_strategy": "custom",
|
||||
"share_addr": "edge.example.com:8443",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed invalid address share fields: %v", err)
|
||||
}
|
||||
|
||||
svc := InboundService{}
|
||||
svc.MigrationRequirements()
|
||||
|
||||
var gotInvalid model.Inbound
|
||||
if err := db.First(&gotInvalid, invalidStrategy.Id).Error; err != nil {
|
||||
t.Fatalf("reload invalid strategy inbound: %v", err)
|
||||
}
|
||||
if gotInvalid.ShareAddrStrategy != "node" || gotInvalid.ShareAddr != "edge.example.com" {
|
||||
t.Fatalf("invalid share fields = (%q, %q), want (node, edge.example.com)", gotInvalid.ShareAddrStrategy, gotInvalid.ShareAddr)
|
||||
}
|
||||
|
||||
var gotPadded model.Inbound
|
||||
if err := db.First(&gotPadded, paddedStrategy.Id).Error; err != nil {
|
||||
t.Fatalf("reload padded strategy inbound: %v", err)
|
||||
}
|
||||
if gotPadded.ShareAddrStrategy != "listen" || gotPadded.ShareAddr != "10.0.0.1" {
|
||||
t.Fatalf("padded share fields = (%q, %q), want (listen, 10.0.0.1)", gotPadded.ShareAddrStrategy, gotPadded.ShareAddr)
|
||||
}
|
||||
|
||||
var gotInvalidAddress model.Inbound
|
||||
if err := db.First(&gotInvalidAddress, invalidAddress.Id).Error; err != nil {
|
||||
t.Fatalf("reload invalid address inbound: %v", err)
|
||||
}
|
||||
if gotInvalidAddress.ShareAddrStrategy != "node" || gotInvalidAddress.ShareAddr != "" {
|
||||
t.Fatalf("invalid address share fields = (%q, %q), want (node, empty)", gotInvalidAddress.ShareAddrStrategy, gotInvalidAddress.ShareAddr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,6 +329,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
ExpiryTime: snapIb.ExpiryTime,
|
||||
Up: snapIb.Up,
|
||||
Down: snapIb.Down,
|
||||
ShareAddrStrategy: "node",
|
||||
}
|
||||
if err := tx.Create(&newIb).Error; err != nil {
|
||||
logger.Warningf("setRemoteTraffic: create central inbound for tag %q failed: %v", snapIb.Tag, err)
|
||||
|
||||
@@ -98,3 +98,85 @@ func TestUpdateInbound_KeepsCustomTagOnPortChange(t *testing.T) {
|
||||
t.Fatalf("returned tag = %q, want my-custom-tag", got.Tag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateInbound_PreservesShareAddressFieldsWhenOmitted(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
|
||||
existing := model.Inbound{
|
||||
Tag: "in-443-tcp",
|
||||
Enable: true,
|
||||
Listen: "0.0.0.0",
|
||||
Port: 443,
|
||||
Protocol: model.VLESS,
|
||||
StreamSettings: `{"network":"tcp"}`,
|
||||
Settings: `{"clients":[]}`,
|
||||
ShareAddrStrategy: "custom",
|
||||
ShareAddr: " edge.example.com ",
|
||||
}
|
||||
if err := database.GetDB().Create(&existing).Error; err != nil {
|
||||
t.Fatalf("seed inbound: %v", err)
|
||||
}
|
||||
|
||||
update := existing
|
||||
update.Remark = "updated"
|
||||
update.ShareAddrStrategy = ""
|
||||
update.ShareAddr = ""
|
||||
|
||||
svc := &InboundService{}
|
||||
got, _, err := svc.UpdateInbound(&update)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateInbound: %v", err)
|
||||
}
|
||||
|
||||
var reloaded model.Inbound
|
||||
if err := database.GetDB().First(&reloaded, existing.Id).Error; err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if reloaded.ShareAddrStrategy != "custom" || reloaded.ShareAddr != "edge.example.com" {
|
||||
t.Fatalf("persisted share fields = (%q, %q), want (custom, edge.example.com)", reloaded.ShareAddrStrategy, reloaded.ShareAddr)
|
||||
}
|
||||
if got.ShareAddrStrategy != "custom" || got.ShareAddr != "edge.example.com" {
|
||||
t.Fatalf("returned share fields = (%q, %q), want (custom, edge.example.com)", got.ShareAddrStrategy, got.ShareAddr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeInboundShareAddressStrict_RequiresHostOnly(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
addr string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "hostname", addr: " edge.example.com ", want: "edge.example.com"},
|
||||
{name: "ipv4", addr: "203.0.113.10", want: "203.0.113.10"},
|
||||
{name: "bare ipv6", addr: "2001:db8::1", want: "[2001:db8::1]"},
|
||||
{name: "bracketed ipv6", addr: "[2001:db8::2]", want: "[2001:db8::2]"},
|
||||
{name: "scheme rejected", addr: "https://edge.example.com", wantErr: true},
|
||||
{name: "port rejected", addr: "edge.example.com:8443", wantErr: true},
|
||||
{name: "bracketed ipv6 port rejected", addr: "[2001:db8::1]:8443", wantErr: true},
|
||||
{name: "path rejected", addr: "edge.example.com/path", wantErr: true},
|
||||
{name: "space rejected", addr: "bad host", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
inbound := &model.Inbound{
|
||||
ShareAddrStrategy: "custom",
|
||||
ShareAddr: tt.addr,
|
||||
}
|
||||
err := normalizeInboundShareAddressStrict(inbound)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("normalizeInboundShareAddressStrict(%q) expected error", tt.addr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeInboundShareAddressStrict(%q): %v", tt.addr, err)
|
||||
}
|
||||
if inbound.ShareAddr != tt.want {
|
||||
t.Fatalf("ShareAddr = %q, want %q", inbound.ShareAddr, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,3 +69,103 @@ func TestSetRemoteTraffic_AttributesOriginNodeGuid(t *testing.T) {
|
||||
t.Fatalf("forwarded inbound origin = %q, want node3-guid (kept across the hop)", og)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetRemoteTraffic_PreservesLocalShareAddressStrategy(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
db := database.GetDB()
|
||||
|
||||
const nodeID = 1
|
||||
if err := db.Create(&model.Node{
|
||||
Id: nodeID,
|
||||
Name: "node2",
|
||||
Address: "10.0.0.2",
|
||||
Port: 2053,
|
||||
ApiToken: "t",
|
||||
Guid: "node2-guid",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
|
||||
nodeIDPtr := nodeID
|
||||
if err := db.Create(&model.Inbound{
|
||||
UserId: 1,
|
||||
NodeID: &nodeIDPtr,
|
||||
Tag: "remote-in",
|
||||
Enable: true,
|
||||
Port: 443,
|
||||
Protocol: model.VLESS,
|
||||
Settings: `{"clients":[]}`,
|
||||
ShareAddrStrategy: "custom",
|
||||
ShareAddr: "edge.example.com",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create central inbound: %v", err)
|
||||
}
|
||||
|
||||
snap := &runtime.TrafficSnapshot{
|
||||
Inbounds: []*model.Inbound{{
|
||||
Tag: "remote-in",
|
||||
Enable: true,
|
||||
Port: 8443,
|
||||
Protocol: model.VLESS,
|
||||
Settings: `{"clients":[]}`,
|
||||
}},
|
||||
}
|
||||
|
||||
svc := InboundService{}
|
||||
if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false); err != nil {
|
||||
t.Fatalf("setRemoteTrafficLocked: %v", err)
|
||||
}
|
||||
|
||||
var ib model.Inbound
|
||||
if err := db.Where("tag = ?", "remote-in").First(&ib).Error; err != nil {
|
||||
t.Fatalf("load inbound: %v", err)
|
||||
}
|
||||
if ib.ShareAddrStrategy != "custom" || ib.ShareAddr != "edge.example.com" {
|
||||
t.Fatalf("share address fields were overwritten: strategy=%q addr=%q", ib.ShareAddrStrategy, ib.ShareAddr)
|
||||
}
|
||||
if ib.Port != 8443 {
|
||||
t.Fatalf("sync should still update regular remote fields; port = %d, want 8443", ib.Port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetRemoteTraffic_DefaultsShareAddressFieldsForNewCentralInbound(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
db := database.GetDB()
|
||||
|
||||
const nodeID = 1
|
||||
if err := db.Create(&model.Node{
|
||||
Id: nodeID,
|
||||
Name: "node2",
|
||||
Address: "10.0.0.2",
|
||||
Port: 2053,
|
||||
ApiToken: "t",
|
||||
Guid: "node2-guid",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
|
||||
snap := &runtime.TrafficSnapshot{
|
||||
Inbounds: []*model.Inbound{{
|
||||
Tag: "remote-in",
|
||||
Enable: true,
|
||||
Port: 8443,
|
||||
Protocol: model.VLESS,
|
||||
Settings: `{"clients":[]}`,
|
||||
ShareAddrStrategy: "custom",
|
||||
ShareAddr: "remote.example.com",
|
||||
}},
|
||||
}
|
||||
|
||||
svc := InboundService{}
|
||||
if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false); err != nil {
|
||||
t.Fatalf("setRemoteTrafficLocked: %v", err)
|
||||
}
|
||||
|
||||
var ib model.Inbound
|
||||
if err := db.Where("tag = ?", "remote-in").First(&ib).Error; err != nil {
|
||||
t.Fatalf("load inbound: %v", err)
|
||||
}
|
||||
if ib.ShareAddrStrategy != "node" || ib.ShareAddr != "" {
|
||||
t.Fatalf("new central inbound share fields = (%q, %q), want (node, empty)", ib.ShareAddrStrategy, ib.ShareAddr)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user