mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-01 19:52:09 +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:
@@ -796,6 +796,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"disableFlow": false,
|
||||
"down": 0,
|
||||
"enable": true,
|
||||
"excludeFromSub": false,
|
||||
"expiryTime": 0,
|
||||
"fallbackParent": null,
|
||||
"id": 1,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -705,6 +705,7 @@ export interface Inbound {
|
||||
disableFlow: boolean;
|
||||
down: number;
|
||||
enable: boolean;
|
||||
excludeFromSub: boolean;
|
||||
expiryTime: number;
|
||||
fallbackParent?: FallbackParentInfo | null;
|
||||
id: number;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user