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:
mrchatam
2026-09-27 13:56:37 +03:30
committed by GitHub
parent 12d51d7195
commit 6f40a75909
36 changed files with 347 additions and 0 deletions
+11
View File
@@ -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)
}
}
+1
View File
@@ -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