Explorar el Código

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 <[email protected]>
Co-authored-by: MHSanaei <[email protected]>
mrchatam hace 10 horas
padre
commit
6f40a75909
Se han modificado 36 ficheros con 347 adiciones y 0 borrados
  1. 8 0
      docs/public/openapi.json
  2. 8 0
      frontend/public/openapi.json
  3. 1 0
      frontend/src/generated/examples.ts
  4. 6 0
      frontend/src/generated/schemas.ts
  5. 1 0
      frontend/src/generated/types.ts
  6. 1 0
      frontend/src/generated/zod.ts
  7. 4 0
      frontend/src/lib/xray/inbound-form-adapter.ts
  8. 3 0
      frontend/src/models/dbinbound.ts
  9. 11 0
      frontend/src/pages/inbounds/form/InboundFormModal.tsx
  10. 1 0
      frontend/src/schemas/forms/inbound-form.ts
  11. 29 0
      frontend/src/test/inbound-form-adapter.test.ts
  12. 11 0
      internal/database/db.go
  13. 58 0
      internal/database/inbound_exclude_from_sub_migration_test.go
  14. 1 0
      internal/database/model/model.go
  15. 6 0
      internal/sub/clash_service.go
  16. 6 0
      internal/sub/json_service.go
  17. 18 0
      internal/sub/service.go
  18. 77 0
      internal/sub/service_exclude_from_sub_test.go
  19. 1 0
      internal/web/runtime/remote.go
  20. 9 0
      internal/web/runtime/remote_test.go
  21. 1 0
      internal/web/service/inbound.go
  22. 57 0
      internal/web/service/inbound_exclude_from_sub_test.go
  23. 3 0
      internal/web/service/inbound_node.go
  24. 2 0
      internal/web/translation/ar-EG.json
  25. 2 0
      internal/web/translation/en-US.json
  26. 2 0
      internal/web/translation/es-ES.json
  27. 2 0
      internal/web/translation/fa-IR.json
  28. 2 0
      internal/web/translation/id-ID.json
  29. 2 0
      internal/web/translation/ja-JP.json
  30. 2 0
      internal/web/translation/pt-BR.json
  31. 2 0
      internal/web/translation/ru-RU.json
  32. 2 0
      internal/web/translation/tr-TR.json
  33. 2 0
      internal/web/translation/uk-UA.json
  34. 2 0
      internal/web/translation/vi-VN.json
  35. 2 0
      internal/web/translation/zh-CN.json
  36. 2 0
      internal/web/translation/zh-TW.json

+ 8 - 0
docs/public/openapi.json

@@ -3054,6 +3054,11 @@
             "example": true,
             "type": "boolean"
           },
+          "excludeFromSub": {
+            "description": "Whether to omit this inbound from subscription output while keeping it operational",
+            "example": false,
+            "type": "boolean"
+          },
           "expiryTime": {
             "description": "Expiration timestamp",
             "format": "int64",
@@ -3177,6 +3182,7 @@
           "disableFlow",
           "down",
           "enable",
+          "excludeFromSub",
           "expiryTime",
           "id",
           "lastTrafficResetTime",
@@ -5162,6 +5168,7 @@
                       "disableFlow": false,
                       "down": 0,
                       "enable": true,
+                      "excludeFromSub": false,
                       "expiryTime": 0,
                       "fallbackParent": null,
                       "id": 1,
@@ -16250,6 +16257,7 @@
                   "disableFlow": false,
                   "down": 0,
                   "enable": true,
+                  "excludeFromSub": false,
                   "expiryTime": 0,
                   "fallbackParent": null,
                   "id": 1,

+ 8 - 0
frontend/public/openapi.json

@@ -3054,6 +3054,11 @@
             "example": true,
             "type": "boolean"
           },
+          "excludeFromSub": {
+            "description": "Whether to omit this inbound from subscription output while keeping it operational",
+            "example": false,
+            "type": "boolean"
+          },
           "expiryTime": {
             "description": "Expiration timestamp",
             "format": "int64",
@@ -3177,6 +3182,7 @@
           "disableFlow",
           "down",
           "enable",
+          "excludeFromSub",
           "expiryTime",
           "id",
           "lastTrafficResetTime",
@@ -5162,6 +5168,7 @@
                       "disableFlow": false,
                       "down": 0,
                       "enable": true,
+                      "excludeFromSub": false,
                       "expiryTime": 0,
                       "fallbackParent": null,
                       "id": 1,
@@ -16250,6 +16257,7 @@
                   "disableFlow": false,
                   "down": 0,
                   "enable": true,
+                  "excludeFromSub": false,
                   "expiryTime": 0,
                   "fallbackParent": null,
                   "id": 1,

+ 1 - 0
frontend/src/generated/examples.ts

@@ -796,6 +796,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "disableFlow": false,
     "down": 0,
     "enable": true,
+    "excludeFromSub": false,
     "expiryTime": 0,
     "fallbackParent": null,
     "id": 1,

+ 6 - 0
frontend/src/generated/schemas.ts

@@ -3028,6 +3028,11 @@ export const SCHEMAS: Record<string, unknown> = {
         "example": true,
         "type": "boolean"
       },
+      "excludeFromSub": {
+        "description": "Whether to omit this inbound from subscription output while keeping it operational",
+        "example": false,
+        "type": "boolean"
+      },
       "expiryTime": {
         "description": "Expiration timestamp",
         "format": "int64",
@@ -3151,6 +3156,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "disableFlow",
       "down",
       "enable",
+      "excludeFromSub",
       "expiryTime",
       "id",
       "lastTrafficResetTime",

+ 1 - 0
frontend/src/generated/types.ts

@@ -705,6 +705,7 @@ export interface Inbound {
   disableFlow: boolean;
   down: number;
   enable: boolean;
+  excludeFromSub: boolean;
   expiryTime: number;
   fallbackParent?: FallbackParentInfo | null;
   id: number;

+ 1 - 0
frontend/src/generated/zod.ts

@@ -748,6 +748,7 @@ export const InboundSchema = z.object({
   disableFlow: z.boolean(),
   down: z.number().int(),
   enable: z.boolean(),
+  excludeFromSub: z.boolean(),
   expiryTime: z.number().int(),
   fallbackParent: z.lazy(() => FallbackParentInfoSchema).nullable().optional(),
   id: z.number().int(),

+ 4 - 0
frontend/src/lib/xray/inbound-form-adapter.ts

@@ -54,6 +54,7 @@ export interface RawInboundRow {
   shareAddrStrategy?: string;
   shareAddr?: string;
   subSortIndex?: number;
+  excludeFromSub?: boolean;
   disableFlow?: boolean;
   clientStats?: unknown;
 }
@@ -83,6 +84,7 @@ export interface WireInboundPayload {
   shareAddrStrategy: ShareAddrStrategy;
   shareAddr: string;
   subSortIndex: number;
+  excludeFromSub: boolean;
   disableFlow: boolean;
 }
 
@@ -219,6 +221,7 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
     shareAddrStrategy: coerceShareAddrStrategy(row.shareAddrStrategy),
     shareAddr: row.shareAddr ?? '',
     subSortIndex: row.subSortIndex == null || row.subSortIndex === 0 ? 1 : row.subSortIndex,
+    excludeFromSub: row.excludeFromSub ?? false,
     disableFlow: row.disableFlow ?? false,
     protocol,
     settings,
@@ -387,6 +390,7 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP
     shareAddrStrategy: values.shareAddrStrategy,
     shareAddr: values.shareAddr,
     subSortIndex: values.subSortIndex,
+    excludeFromSub: values.excludeFromSub,
     disableFlow: values.disableFlow,
   };
   if (values.nodeId != null) payload.nodeId = values.nodeId;

+ 3 - 0
frontend/src/models/dbinbound.ts

@@ -44,6 +44,7 @@ export type DBInboundInit = Partial<{
   shareAddrStrategy: string;
   shareAddr: string;
   subSortIndex: number;
+  excludeFromSub: boolean;
   disableFlow: boolean;
   originNodeGuid: string;
   fallbackParent: FallbackParentRef | null;
@@ -93,6 +94,7 @@ export class DBInbound {
   shareAddrStrategy: string;
   shareAddr: string;
   subSortIndex: number;
+  excludeFromSub: boolean;
   disableFlow: boolean;
   originNodeGuid: string;
   fallbackParent: FallbackParentRef | null;
@@ -124,6 +126,7 @@ export class DBInbound {
     this.shareAddrStrategy = 'node';
     this.shareAddr = '';
     this.subSortIndex = 1;
+    this.excludeFromSub = false;
     this.disableFlow = false;
     this.originNodeGuid = '';
     this.fallbackParent = null;

+ 11 - 0
frontend/src/pages/inbounds/form/InboundFormModal.tsx

@@ -700,6 +700,17 @@ export default function InboundFormModal({
         <InputNumber />
       </FormField>
 
+      <FormField
+        name="excludeFromSub"
+        valueProp="checked"
+        label={labelWithHint(
+          t('pages.inbounds.form.excludeFromSub'),
+          t('pages.inbounds.form.excludeFromSubHelp'),
+        )}
+      >
+        <Switch />
+      </FormField>
+
       {protocol === Protocols.VLESS && (
         <FormField
           name="disableFlow"

+ 1 - 0
frontend/src/schemas/forms/inbound-form.ts

@@ -81,6 +81,7 @@ export const InboundDbFieldsSchema = z.object({
   shareAddrStrategy: ShareAddrStrategySchema.default('node'),
   shareAddr: z.string().default(''),
   subSortIndex: z.number().int().default(1),
+  excludeFromSub: z.boolean().default(false),
   disableFlow: z.boolean().default(false),
 });
 export type InboundDbFields = z.infer<typeof InboundDbFieldsSchema>;

+ 29 - 0
frontend/src/test/inbound-form-adapter.test.ts

@@ -294,6 +294,35 @@ describe('formValuesToWirePayload', () => {
   });
 });
 
+describe('excludeFromSub', () => {
+  it('DBInbound constructor preserves excludeFromSub from the API row', () => {
+    expect(new DBInbound({ excludeFromSub: true }).excludeFromSub).toBe(true);
+    expect(new DBInbound({ excludeFromSub: false }).excludeFromSub).toBe(false);
+  });
+
+  it('DBInbound defaults excludeFromSub to false when the API omits it', () => {
+    expect(new DBInbound({ protocol: 'vless' }).excludeFromSub).toBe(false);
+    expect(new DBInbound().excludeFromSub).toBe(false);
+  });
+
+  it('rawInboundToFormValues reads excludeFromSub and defaults to false', () => {
+    expect(rawInboundToFormValues({ ...vlessRow, excludeFromSub: true }).excludeFromSub).toBe(true);
+    expect(rawInboundToFormValues(vlessRow).excludeFromSub).toBe(false);
+  });
+
+  it('formValuesToWirePayload includes excludeFromSub', () => {
+    const values = rawInboundToFormValues({ ...vlessRow, excludeFromSub: true });
+    expect(formValuesToWirePayload(values).excludeFromSub).toBe(true);
+  });
+
+  it('excludeFromSub survives raw → DBInbound → values → payload (the edit round-trip)', () => {
+    const db = new DBInbound({ ...vlessRow, excludeFromSub: true } as unknown as DBInboundInit);
+    const values = rawInboundToFormValues(db as unknown as RawInboundRow);
+    const payload = formValuesToWirePayload(values);
+    expect(payload.excludeFromSub).toBe(true);
+  });
+});
+
 describe('disableFlow', () => {
   it('DBInbound constructor preserves disableFlow from the API row', () => {
     expect(new DBInbound({ disableFlow: true }).disableFlow).toBe(true);

+ 11 - 0
internal/database/db.go

@@ -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) {

+ 58 - 0
internal/database/inbound_exclude_from_sub_migration_test.go

@@ -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 - 0
internal/database/model/model.go

@@ -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

+ 6 - 0
internal/sub/clash_service.go

@@ -63,6 +63,12 @@ func (s *SubClashService) getClash(subId string, host string, legacy bool) (stri
 		if len(clients) == 0 {
 			continue
 		}
+		if inbound.ExcludeFromSub {
+			if countHiddenClients(clients, seenEmails) {
+				hasEnabledClient = true
+			}
+			continue
+		}
 		subReq.projectThroughFallbackMaster(inbound)
 		if hostEps := subReq.hostEndpoints(inbound, "clash"); len(hostEps) > 0 {
 			injectExternalProxy(inbound, hostEps)

+ 6 - 0
internal/sub/json_service.go

@@ -141,6 +141,12 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
 		if len(clients) == 0 {
 			continue
 		}
+		if inbound.ExcludeFromSub {
+			if countHiddenClients(clients, seenEmails) {
+				hasEnabledClient = true
+			}
+			continue
+		}
 		subReq.projectThroughFallbackMaster(inbound)
 		if hostEps := subReq.hostEndpoints(inbound, "json"); len(hostEps) > 0 {
 			injectExternalProxy(inbound, hostEps)

+ 18 - 0
internal/sub/service.go

@@ -330,6 +330,18 @@ func (s *SubService) matchingClients(inbound *model.Inbound, subId string) []mod
 	return out
 }
 
+// countHiddenClients adds an excludeFromSub inbound's clients to the usage set:
+// the inbound still serves them, so only its links leave the subscription.
+func countHiddenClients(clients []model.Client, seenEmails map[string]struct{}) (anyEnabled bool) {
+	for _, client := range clients {
+		seenEmails[client.Email] = struct{}{}
+		if client.Enable {
+			anyEnabled = true
+		}
+	}
+	return anyEnabled
+}
+
 // overlayInboundTunnelIdentity copies per-inbound tunnel fields from settings.
 // An unmatched peer is dropped, malformed settings yield nothing, and empty optional secrets replace shared values (#6641).
 func (s *SubService) overlayInboundTunnelIdentity(inbound *model.Inbound, clients []model.Client) ([]model.Client, error) {
@@ -473,6 +485,12 @@ func (s *SubService) getSubs(subId string) ([]string, []string, int64, xray.Clie
 		if len(clients) == 0 {
 			continue
 		}
+		if inbound.ExcludeFromSub {
+			if countHiddenClients(clients, seenEmails) {
+				hasEnabledClient = true
+			}
+			continue
+		}
 		s.projectThroughFallbackMaster(inbound)
 		// Host overrides apply AFTER fallback projection so a host's
 		// address/TLS wins over the projected master stream.

+ 77 - 0
internal/sub/service_exclude_from_sub_test.go

@@ -0,0 +1,77 @@
+package sub
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+// An excluded inbound keeps serving its clients, so every renderer must drop
+// its links yet still count its clients' usage in the Subscription-Userinfo header.
+func TestExcludedInboundHidesLinksButKeepsUsage(t *testing.T) {
+	seedSubDB(t)
+	db := database.GetDB()
+
+	shown := seedSubInbound(t, "sub-excl", "shown", 24401, 1, `{"network":"tcp","security":"none"}`)
+	hidden := seedSubInbound(t, "sub-excl", "hidden", 24402, 2, `{"network":"tcp","security":"none"}`)
+	if err := db.Model(hidden).Update("exclude_from_sub", true).Error; err != nil {
+		t.Fatalf("mark excluded: %v", err)
+	}
+	for _, row := range []*xray.ClientTraffic{
+		{InboundId: shown.Id, Email: "shown@e", Up: 100, Down: 200, Enable: true},
+		{InboundId: hidden.Id, Email: "hidden@e", Up: 1000, Down: 2000, Enable: true},
+	} {
+		if err := db.Create(row).Error; err != nil {
+			t.Fatalf("seed traffic %s: %v", row.Email, err)
+		}
+	}
+
+	const wantHeader = "upload=1100; download=2200; "
+	assertOnlyShown := func(t *testing.T, out string) {
+		t.Helper()
+		if !strings.Contains(out, "24401") {
+			t.Fatalf("output lost the shown inbound:\n%s", out)
+		}
+		if strings.Contains(out, "24402") {
+			t.Fatalf("output leaked the excluded inbound:\n%s", out)
+		}
+	}
+
+	t.Run("raw", func(t *testing.T) {
+		links, _, _, traffic, err := NewSubService("").GetSubs("sub-excl", "req.example.com")
+		if err != nil {
+			t.Fatalf("GetSubs: %v", err)
+		}
+		if len(links) != 1 {
+			t.Fatalf("links = %q, want only the shown inbound's link", links)
+		}
+		assertOnlyShown(t, links[0])
+		if traffic.Up != 1100 || traffic.Down != 2200 {
+			t.Fatalf("usage = up %d/down %d, want 1100/2200 including the excluded inbound's client", traffic.Up, traffic.Down)
+		}
+	})
+
+	t.Run("clash", func(t *testing.T) {
+		out, header, err := NewSubClashService(false, "", NewSubService("")).GetClash("sub-excl", "req.example.com")
+		if err != nil {
+			t.Fatalf("GetClash: %v", err)
+		}
+		assertOnlyShown(t, out)
+		if !strings.HasPrefix(header, wantHeader) {
+			t.Fatalf("header = %q, want prefix %q", header, wantHeader)
+		}
+	})
+
+	t.Run("json", func(t *testing.T) {
+		out, header, err := NewSubJsonService("", "", "", "", NewSubService("")).GetJson("sub-excl", "req.example.com", false)
+		if err != nil {
+			t.Fatalf("GetJson: %v", err)
+		}
+		assertOnlyShown(t, out)
+		if !strings.HasPrefix(header, wantHeader) {
+			t.Fatalf("header = %q, want prefix %q", header, wantHeader)
+		}
+	})
+}

+ 1 - 0
internal/web/runtime/remote.go

@@ -810,6 +810,7 @@ func wireInbound(ib *model.Inbound, remoteNodeID int) url.Values {
 	v.Set("total", strconv.FormatInt(ib.Total, 10))
 	v.Set("remark", ib.Remark)
 	v.Set("subSortIndex", strconv.Itoa(ib.SubSortIndex))
+	v.Set("excludeFromSub", strconv.FormatBool(ib.ExcludeFromSub))
 	v.Set("enable", strconv.FormatBool(ib.Enable))
 	v.Set("expiryTime", strconv.FormatInt(ib.ExpiryTime, 10))
 	v.Set("listen", ib.Listen)

+ 9 - 0
internal/web/runtime/remote_test.go

@@ -196,6 +196,15 @@ func TestWireInboundCarriesDisableFlow(t *testing.T) {
 	}
 }
 
+func TestWireInboundCarriesExcludeFromSub(t *testing.T) {
+	if got := wireInbound(&model.Inbound{ExcludeFromSub: true}, 0).Get("excludeFromSub"); got != "true" {
+		t.Fatalf("excludeFromSub = %q, want true", got)
+	}
+	if got := wireInbound(&model.Inbound{}, 0).Get("excludeFromSub"); got != "false" {
+		t.Fatalf("excludeFromSub = %q, want false", got)
+	}
+}
+
 func TestRemoteHTTPClientEgressProxy(t *testing.T) {
 	// OutboundTag + a resolver → a dedicated proxy client (not the shared default).
 	withTag := NewRemote(&model.Node{Id: 1, Scheme: "https", TlsVerifyMode: "verify", OutboundTag: "warp"}, stubEgress{url: "socks5://127.0.0.1:1080"})

+ 1 - 0
internal/web/service/inbound.go

@@ -1864,6 +1864,7 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 		oldInbound.Total = inbound.Total
 		oldInbound.Remark = inbound.Remark
 		oldInbound.SubSortIndex = inbound.SubSortIndex
+		oldInbound.ExcludeFromSub = inbound.ExcludeFromSub
 		oldInbound.Enable = inbound.Enable
 		oldInbound.ExpiryTime = inbound.ExpiryTime
 		oldInbound.TrafficReset = inbound.TrafficReset

+ 57 - 0
internal/web/service/inbound_exclude_from_sub_test.go

@@ -0,0 +1,57 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func TestUpdateInbound_PersistsExcludeFromSub(t *testing.T) {
+	setupConflictDB(t)
+
+	ib := makeInboundWithSubSortIndex("in-7004-tcp", 7004, 1)
+	if err := database.GetDB().Create(ib).Error; err != nil {
+		t.Fatalf("create inbound: %v", err)
+	}
+
+	update := *ib
+	update.ExcludeFromSub = true
+	got, _, err := (&InboundService{}).UpdateInbound(&update)
+	if err != nil {
+		t.Fatalf("UpdateInbound: %v", err)
+	}
+	if !got.ExcludeFromSub {
+		t.Fatal("returned ExcludeFromSub = false, want true")
+	}
+
+	var reloaded model.Inbound
+	if err := database.GetDB().First(&reloaded, ib.Id).Error; err != nil {
+		t.Fatalf("reload: %v", err)
+	}
+	if !reloaded.ExcludeFromSub {
+		t.Fatal("persisted ExcludeFromSub = false, want true")
+	}
+}
+
+func TestAddInbound_PersistsExcludeFromSub(t *testing.T) {
+	setupConflictDB(t)
+
+	ib := makeInboundWithSubSortIndex("in-7005-tcp", 7005, 1)
+	ib.ExcludeFromSub = true
+	got, _, err := (&InboundService{}).AddInbound(ib)
+	if err != nil {
+		t.Fatalf("AddInbound: %v", err)
+	}
+	if !got.ExcludeFromSub {
+		t.Fatal("returned ExcludeFromSub = false, want true")
+	}
+
+	var reloaded model.Inbound
+	if err := database.GetDB().First(&reloaded, got.Id).Error; err != nil {
+		t.Fatalf("reload: %v", err)
+	}
+	if !reloaded.ExcludeFromSub {
+		t.Fatal("persisted ExcludeFromSub = false, want true")
+	}
+}

+ 3 - 0
internal/web/service/inbound_node.go

@@ -412,6 +412,8 @@ func adoptedWireInbound(c, snapIb *model.Inbound, adoptedSettings string) *model
 	a.Enable = snapIb.Enable
 	a.Remark = snapIb.Remark
 	a.SubSortIndex = normalizeSubSortIndex(snapIb.SubSortIndex)
+	// ExcludeFromSub stays master-authored: older nodes omit the field and
+	// would otherwise reset it to false on every heartbeat mirror.
 	a.Listen = snapIb.Listen
 	a.Port = snapIb.Port
 	a.Protocol = snapIb.Protocol
@@ -736,6 +738,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 				Enable:               snapIb.Enable,
 				Remark:               snapIb.Remark,
 				SubSortIndex:         normalizeSubSortIndex(snapIb.SubSortIndex),
+				ExcludeFromSub:       snapIb.ExcludeFromSub,
 				Total:                snapIb.Total,
 				ExpiryTime:           snapIb.ExpiryTime,
 				Up:                   snapIb.Up,

+ 2 - 0
internal/web/translation/ar-EG.json

@@ -664,6 +664,8 @@
         "shareAddrHelp": "يُستخدم فقط عندما تكون استراتيجية عنوان المشاركة مخصصة. أدخل اسم مضيف أو عنوان IP بدون بروتوكول أو منفذ.",
         "subSortIndex": "ترتيب الروابط في الاشتراك",
         "subSortIndexHelp": "موضع روابط هذا الوارد في مخرجات الاشتراك (صفحة الاشتراك وتطبيقات العملاء). القيم الأقل تظهر أولاً، والقيم المتساوية تحافظ على ترتيب الإنشاء. لا يؤثر على قائمة الواردات في اللوحة.",
+        "excludeFromSub": "استبعاد من الاشتراكات",
+        "excludeFromSubHelp": "إخفاء روابط هذا الإدخال من مخرجات الاشتراك مع الإبقاء على الإدخال مفعّلاً ويعمل.",
         "disableFlow": "تعطيل تدفق XTLS",
         "disableFlowHelp": "استثناء هذا الـ inbound من الحقن التلقائي لـ xtls-rprx-vision، حتى عندما يكون النقل قادرًا على الـ flow (مثل inbound من نوع XHTTP عبر نفق مع تشفير VLESS). يحتفظ العملاء بـ Vision على باقي الـ inbounds القادرة ضمن نفس الاشتراك. لـ VLESS فقط.",
         "shareAddrStrategyOptions": {

+ 2 - 0
internal/web/translation/en-US.json

@@ -687,6 +687,8 @@
         "shareAddrHelp": "Used only when the share address strategy is Custom. Enter a host or IP without a scheme or port.",
         "subSortIndex": "Subscription sort order",
         "subSortIndexHelp": "Position of this inbound's links in subscription output (sub page and client apps). Lower values come first; equal values keep creation order. Does not affect the panel inbound list.",
+        "excludeFromSub": "Exclude from subscriptions",
+        "excludeFromSubHelp": "Hide this inbound's links from subscription output while keeping the inbound enabled and operational.",
         "disableFlow": "Disable XTLS flow",
         "disableFlowHelp": "Opt this inbound out of automatic xtls-rprx-vision injection, even when its transport is flow-capable (e.g. a tunneled XHTTP inbound with VLESS encryption). Clients keep Vision on your other capable inbounds in the same subscription. VLESS only.",
         "shareAddrStrategyOptions": {

+ 2 - 0
internal/web/translation/es-ES.json

@@ -685,6 +685,8 @@
         "shareAddrHelp": "Solo se usa cuando la estrategia de dirección para compartir es Personalizada. Introduce un host o IP sin esquema ni puerto.",
         "subSortIndex": "Orden en la suscripción",
         "subSortIndexHelp": "Posición de los enlaces de esta entrada en la salida de la suscripción (página de suscripción y apps cliente). Los valores más bajos van primero; con valores iguales se mantiene el orden de creación. No afecta a la lista de entradas del panel.",
+        "excludeFromSub": "Excluir de las suscripciones",
+        "excludeFromSubHelp": "Oculta los enlaces de esta entrada en la salida de suscripción manteniendo la entrada habilitada y operativa.",
         "disableFlow": "Desactivar el flujo XTLS",
         "disableFlowHelp": "Excluye este inbound de la inyección automática de xtls-rprx-vision, incluso cuando su transporte admite flow (p. ej. un inbound XHTTP tunelizado con cifrado VLESS). Los clientes mantienen Vision en tus demás inbounds compatibles de la misma suscripción. Solo VLESS.",
         "shareAddrStrategyOptions": {

+ 2 - 0
internal/web/translation/fa-IR.json

@@ -685,6 +685,8 @@
         "shareAddrHelp": "فقط زمانی استفاده می‌شود که راهبرد آدرس اشتراک‌گذاری روی سفارشی باشد. میزبان یا IP را بدون طرح و پورت وارد کنید.",
         "subSortIndex": "ترتیب در اشتراک",
         "subSortIndexHelp": "جایگاه لینک‌های این ورودی در خروجی اشتراک (صفحه اشتراک و برنامه‌های کلاینت). مقدار کمتر اول می‌آید و مقدارهای برابر ترتیب ایجاد را حفظ می‌کنند. روی فهرست ورودی‌های پنل تأثیری ندارد.",
+        "excludeFromSub": "حذف از اشتراک‌ها",
+        "excludeFromSubHelp": "لینک‌های این ورودی را از خروجی اشتراک پنهان می‌کند، در حالی که ورودی همچنان فعال و عملیاتی می‌ماند.",
         "disableFlow": "غیرفعال‌کردن جریان XTLS",
         "disableFlowHelp": "این inbound را از تزریق خودکار xtls-rprx-vision کنار بگذارید، حتی وقتی ترنسپورت آن از flow پشتیبانی می‌کند (مثلاً یک inbound از نوع XHTTP تونل‌شده با رمزنگاری VLESS). کلاینت‌ها Vision را روی سایر inboundهای سازگار در همان اشتراک حفظ می‌کنند. فقط برای VLESS.",
         "shareAddrStrategyOptions": {

+ 2 - 0
internal/web/translation/id-ID.json

@@ -664,6 +664,8 @@
         "shareAddrHelp": "Hanya digunakan saat strategi alamat berbagi adalah Kustom. Masukkan host atau IP tanpa skema atau port.",
         "subSortIndex": "Urutan dalam langganan",
         "subSortIndexHelp": "Posisi tautan inbound ini dalam keluaran langganan (halaman langganan dan aplikasi klien). Nilai lebih kecil tampil lebih dulu; nilai sama mempertahankan urutan pembuatan. Tidak memengaruhi daftar inbound di panel.",
+        "excludeFromSub": "Kecualikan dari langganan",
+        "excludeFromSubHelp": "Sembunyikan tautan inbound ini dari keluaran langganan sambil tetap menjaga inbound tetap aktif dan beroperasi.",
         "disableFlow": "Nonaktifkan flow XTLS",
         "disableFlowHelp": "Kecualikan inbound ini dari injeksi otomatis xtls-rprx-vision, meskipun transport-nya mendukung flow (mis. inbound XHTTP yang dituneling dengan enkripsi VLESS). Klien tetap memakai Vision pada inbound lain yang mendukung dalam langganan yang sama. Hanya VLESS.",
         "shareAddrStrategyOptions": {

+ 2 - 0
internal/web/translation/ja-JP.json

@@ -685,6 +685,8 @@
         "shareAddrHelp": "共有アドレス戦略がカスタムの場合のみ使用されます。スキームやポートを含めずにホスト名またはIPを入力してください。",
         "subSortIndex": "サブスクリプションでの並び順",
         "subSortIndexHelp": "サブスクリプション出力(サブスクリプションページおよびクライアントアプリ)におけるこのインバウンドのリンクの位置。値が小さいほど先頭に表示され、同じ値の場合は作成順が維持されます。パネルのインバウンド一覧には影響しません。",
+        "excludeFromSub": "サブスクリプションから除外",
+        "excludeFromSubHelp": "このインバウンドを有効なまま運用しつつ、サブスクリプション出力からリンクを隠します。",
         "disableFlow": "XTLS フローを無効化",
         "disableFlowHelp": "トランスポートが flow に対応している場合でも(例: VLESS 暗号化付きのトンネル化された XHTTP インバウンド)、このインバウンドを xtls-rprx-vision の自動付与から除外します。クライアントは同じサブスクリプション内の他の対応インバウンドでは Vision を維持します。VLESS のみ。",
         "shareAddrStrategyOptions": {

+ 2 - 0
internal/web/translation/pt-BR.json

@@ -685,6 +685,8 @@
         "shareAddrHelp": "Usado apenas quando a estratégia de endereço de compartilhamento é Personalizada. Informe um host ou IP sem esquema nem porta.",
         "subSortIndex": "Ordem na assinatura",
         "subSortIndexHelp": "Posição dos links desta entrada na saída da assinatura (página de assinatura e aplicativos cliente). Valores menores vêm primeiro; valores iguais mantêm a ordem de criação. Não afeta a lista de entradas do painel.",
+        "excludeFromSub": "Excluir das assinaturas",
+        "excludeFromSubHelp": "Oculta os links deste inbound na saída de assinatura, mantendo o inbound habilitado e operacional.",
         "disableFlow": "Desativar o flow XTLS",
         "disableFlowHelp": "Exclui este inbound da injeção automática de xtls-rprx-vision, mesmo quando o transporte suporta flow (ex.: um inbound XHTTP tunelado com criptografia VLESS). Os clientes mantêm o Vision nos seus outros inbounds compatíveis da mesma assinatura. Somente VLESS.",
         "shareAddrStrategyOptions": {

+ 2 - 0
internal/web/translation/ru-RU.json

@@ -687,6 +687,8 @@
         "shareAddrHelp": "Используется только когда стратегия адреса для ссылок — пользовательская. Укажите хост или IP без схемы и порта.",
         "subSortIndex": "Порядок в подписке",
         "subSortIndexHelp": "Позиция ссылок этого входящего в выдаче подписки (страница подписки и клиентские приложения). Меньшие значения идут первыми; при равных значениях сохраняется порядок создания. Не влияет на список входящих в панели.",
+        "excludeFromSub": "Исключить из подписок",
+        "excludeFromSubHelp": "Скрывает ссылки этого inbound из вывода подписки, оставляя inbound включённым и рабочим.",
         "disableFlow": "Отключить поток XTLS",
         "disableFlowHelp": "Исключить этот inbound из автоматического добавления xtls-rprx-vision, даже если его транспорт поддерживает flow (например, туннелированный XHTTP inbound с шифрованием VLESS). Клиенты сохраняют Vision на других подходящих inbound в той же подписке. Только VLESS.",
         "shareAddrStrategyOptions": {

+ 2 - 0
internal/web/translation/tr-TR.json

@@ -664,6 +664,8 @@
         "shareAddrHelp": "Yalnızca paylaşım adresi stratejisi Özel olduğunda kullanılır. Şema veya port olmadan bir ana makine ya da IP girin.",
         "subSortIndex": "Abonelikte sıralama",
         "subSortIndexHelp": "Bu gelen bağlantının linklerinin abonelik çıktısındaki (abonelik sayfası ve istemci uygulamaları) konumu. Küçük değerler önce gelir; eşit değerlerde oluşturulma sırası korunur. Paneldeki gelen bağlantı listesini etkilemez.",
+        "excludeFromSub": "Aboneliklerden hariç tut",
+        "excludeFromSubHelp": "Bu inbound bağlantılarını abonelik çıktısından gizler; inbound etkin ve çalışır durumda kalır.",
         "disableFlow": "XTLS akışını devre dışı bırak",
         "disableFlowHelp": "Taşıması flow destekliyor olsa bile (ör. VLESS şifrelemeli, tünellenmiş bir XHTTP inbound) bu inbound'u otomatik xtls-rprx-vision eklemenin dışında tut. İstemciler aynı abonelikteki diğer uygun inbound'larda Vision'ı korur. Yalnızca VLESS.",
         "shareAddrStrategyOptions": {

+ 2 - 0
internal/web/translation/uk-UA.json

@@ -664,6 +664,8 @@
         "shareAddrHelp": "Використовується лише коли стратегія адреси поширення — користувацька. Введіть хост або IP без схеми та порту.",
         "subSortIndex": "Порядок у підписці",
         "subSortIndexHelp": "Позиція посилань цього вхідного у виводі підписки (сторінка підписки та клієнтські застосунки). Менші значення йдуть першими; за однакових значень зберігається порядок створення. Не впливає на список вхідних у панелі.",
+        "excludeFromSub": "Виключити з підписок",
+        "excludeFromSubHelp": "Приховує посилання цього inbound у виводі підписки, залишаючи inbound увімкненим і робочим.",
         "disableFlow": "Вимкнути потік XTLS",
         "disableFlowHelp": "Виключити цей inbound з автоматичного додавання xtls-rprx-vision, навіть якщо його транспорт підтримує flow (наприклад, тунельований XHTTP inbound із шифруванням VLESS). Клієнти зберігають Vision на інших сумісних inbound у тій самій підписці. Лише VLESS.",
         "echSockopt": "ECH Sockopt",

+ 2 - 0
internal/web/translation/vi-VN.json

@@ -685,6 +685,8 @@
         "shareAddrHelp": "Chỉ dùng khi chiến lược địa chỉ chia sẻ là Tùy chỉnh. Nhập host hoặc IP không kèm giao thức hoặc cổng.",
         "subSortIndex": "Thứ tự trong gói đăng ký",
         "subSortIndexHelp": "Vị trí liên kết của inbound này trong nội dung gói đăng ký (trang đăng ký và ứng dụng khách). Giá trị nhỏ hơn xếp trước; giá trị bằng nhau giữ thứ tự tạo. Không ảnh hưởng đến danh sách inbound trong bảng điều khiển.",
+        "excludeFromSub": "Loại khỏi subscription",
+        "excludeFromSubHelp": "Ẩn liên kết inbound này khỏi đầu ra subscription nhưng vẫn giữ inbound được bật và hoạt động.",
         "disableFlow": "Tắt luồng XTLS",
         "disableFlowHelp": "Loại inbound này khỏi việc tự động thêm xtls-rprx-vision, ngay cả khi transport của nó hỗ trợ flow (ví dụ một inbound XHTTP đi qua tunnel với mã hóa VLESS). Client vẫn giữ Vision trên các inbound tương thích khác trong cùng subscription. Chỉ dành cho VLESS.",
         "shareAddrStrategyOptions": {

+ 2 - 0
internal/web/translation/zh-CN.json

@@ -684,6 +684,8 @@
         "shareAddrHelp": "仅在分享地址策略为自定义时使用。填写不带协议和端口的域名或 IP。",
         "subSortIndex": "订阅排序",
         "subSortIndexHelp": "此入站的链接在订阅输出(订阅页面和客户端应用)中的位置。数值越小越靠前;数值相同时保持创建顺序。不影响面板中的入站列表。",
+        "excludeFromSub": "从订阅中排除",
+        "excludeFromSubHelp": "从订阅输出中隐藏此入站的链接,同时保持入站启用并继续工作。",
         "disableFlow": "禁用 XTLS flow",
         "disableFlowHelp": "让此入站跳过自动注入 xtls-rprx-vision,即使其传输支持 flow(例如启用 VLESS 加密的隧道化 XHTTP 入站)。客户端在同一订阅中的其他可用入站上仍保留 Vision。仅限 VLESS。",
         "shareAddrStrategyOptions": {

+ 2 - 0
internal/web/translation/zh-TW.json

@@ -685,6 +685,8 @@
         "shareAddrHelp": "僅在分享地址策略為自訂時使用。填寫不帶協定和連接埠的網域或 IP。",
         "subSortIndex": "訂閱排序",
         "subSortIndexHelp": "此入站的連結在訂閱輸出(訂閱頁面和客戶端應用)中的位置。數值越小越靠前;數值相同時保持建立順序。不影響面板中的入站清單。",
+        "excludeFromSub": "從訂閱中排除",
+        "excludeFromSubHelp": "從訂閱輸出中隱藏此入站的連結,同時保持入站啟用並繼續運作。",
         "disableFlow": "停用 XTLS flow",
         "disableFlowHelp": "讓此入站略過自動注入 xtls-rprx-vision,即使其傳輸支援 flow(例如啟用 VLESS 加密的通道化 XHTTP 入站)。用戶端在同一訂閱中的其他可用入站上仍保留 Vision。僅限 VLESS。",
         "shareAddrStrategyOptions": {