mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-30 19:22:10 +03:00
feat(inbound): excludeFromSub hides links without disabling (#6463)
* feat(inbound): excludeFromSub hides links without disabling Add a per-inbound flag that omits subscription output while keeping the inbound enabled for Xray, auth, and traffic accounting. Fixes #6435. * fix(inbound): excludeFromSub review follow-ups gofumpt model.go, sync docs OpenAPI, keep excludeFromSub master-authored on node mirror, and exercise the legacy add-column migration path in tests. * fix(sub): keep excluded inbounds' clients in the usage header The excludeFromSub filter sat in getInboundsBySubId's SQL, so an excluded inbound's clients never reached seenEmails in the raw, Clash or JSON renderer. A client that lives only on a hidden inbound (one client per inbound sharing a subId) dropped out of the Subscription-Userinfo usage, quota and expiry and out of the info-node state, while the inbound kept serving it and counting its traffic. The query returns every enabled inbound again; each renderer skips an excluded inbound's links but still counts its clients, the same rule the Clash renderer already applies to external links it cannot express. --------- Co-authored-by: mrchatam <mrchatam@users.noreply.github.com> Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
@@ -104,6 +104,14 @@ func migrateOutboundSubscriptionUserAgentColumn() error {
|
||||
return migrator.AddColumn(&model.OutboundSubscription{}, "UserAgent")
|
||||
}
|
||||
|
||||
func migrateInboundExcludeFromSubColumn() error {
|
||||
migrator := db.Migrator()
|
||||
if !migrator.HasTable(&model.Inbound{}) || migrator.HasColumn(&model.Inbound{}, "exclude_from_sub") {
|
||||
return nil
|
||||
}
|
||||
return migrator.AddColumn(&model.Inbound{}, "ExcludeFromSub")
|
||||
}
|
||||
|
||||
func initModels() error {
|
||||
if err := migrateClientTrafficLastSubFetchColumn(); err != nil {
|
||||
return err
|
||||
@@ -111,6 +119,9 @@ func initModels() error {
|
||||
if err := migrateOutboundSubscriptionUserAgentColumn(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migrateInboundExcludeFromSubColumn(); err != nil {
|
||||
return err
|
||||
}
|
||||
models := allModels()
|
||||
for _, mdl := range models {
|
||||
if IsPostgres() && postgresModelSettled(mdl) {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Legacy inbounds schema without exclude_from_sub — the upgrade path AddColumn must cover.
|
||||
const legacyInboundNoExcludeFromSubDDL = "CREATE TABLE `inbounds` (`id` integer PRIMARY KEY AUTOINCREMENT,`user_id` integer,`up` integer,`down` integer,`total` integer,`remark` text,`enable` numeric,`expiry_time` integer,`listen` text,`port` integer,`protocol` text,`settings` text,`stream_settings` text,`tag` text UNIQUE,`sniffing` text)"
|
||||
|
||||
func TestMigrateInboundExcludeFromSubColumn(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "x-ui.db")
|
||||
legacy, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open legacy db: %v", err)
|
||||
}
|
||||
if err := legacy.Exec(legacyInboundNoExcludeFromSubDDL).Error; err != nil {
|
||||
t.Fatalf("create legacy inbounds: %v", err)
|
||||
}
|
||||
if err := legacy.Exec(
|
||||
`INSERT INTO inbounds (user_id, remark, enable, port, protocol, settings, stream_settings, tag, sniffing)
|
||||
VALUES (1, 'preexisting', 1, 443, 'vless', '{"clients":[]}', '{}', 'in-443-tcp', '{}')`,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("seed legacy inbound: %v", err)
|
||||
}
|
||||
sqlDB, err := legacy.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("legacy db handle: %v", err)
|
||||
}
|
||||
if err := sqlDB.Close(); err != nil {
|
||||
t.Fatalf("close legacy db: %v", err)
|
||||
}
|
||||
|
||||
if err := InitDB(dbPath); err != nil {
|
||||
t.Fatalf("InitDB over legacy schema: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = CloseDB() })
|
||||
|
||||
if !GetDB().Migrator().HasColumn(&model.Inbound{}, "exclude_from_sub") {
|
||||
t.Fatal("exclude_from_sub column missing after migrateInboundExcludeFromSubColumn")
|
||||
}
|
||||
|
||||
var row model.Inbound
|
||||
if err := GetDB().Where("tag = ?", "in-443-tcp").First(&row).Error; err != nil {
|
||||
t.Fatalf("preexisting inbound lost: %v", err)
|
||||
}
|
||||
if row.ExcludeFromSub {
|
||||
t.Fatal("preexisting row must default exclude_from_sub to false, got true")
|
||||
}
|
||||
if err := migrateInboundExcludeFromSubColumn(); err != nil {
|
||||
t.Fatalf("idempotent migrate: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ type Inbound struct {
|
||||
Total int64 `json:"total" form:"total"` // Total traffic limit in bytes
|
||||
Remark string `json:"remark" form:"remark" example:"VLESS-443"` // Human-readable remark
|
||||
SubSortIndex int `json:"subSortIndex" form:"subSortIndex" gorm:"default:1" validate:"omitempty" example:"1"` // Sort order of this inbound's links in subscription output only (lower first; negatives allowed; 0/omitted → 1; ties by id)
|
||||
ExcludeFromSub bool `json:"excludeFromSub" form:"excludeFromSub" gorm:"column:exclude_from_sub;default:false" example:"false"` // Whether to omit this inbound from subscription output while keeping it operational
|
||||
Enable bool `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1" example:"true"` // Whether the inbound is enabled
|
||||
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
||||
TrafficReset string `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2" validate:"omitempty,oneof=never hourly daily weekly monthly"` // Traffic reset schedule
|
||||
|
||||
Reference in New Issue
Block a user