Files
3x-ui/frontend/src/schemas/forms/inbound-form.ts
T
mrchatam 6f40a75909 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>
2026-09-27 12:26:37 +02:00

125 lines
4.8 KiB
TypeScript

import { z } from 'zod';
import { InboundPortSchema, SniffingSchema } from '@/schemas/primitives';
import { InboundSettingsSchema } from '@/schemas/protocols/inbound';
import {
TlsCertInlineSchema,
TlsStreamSettingsSchema,
securitySettingsSchemaFor,
tlsCertUsesFiles,
} from '@/schemas/protocols/security';
import { NetworkSettingsSchema, StreamExtrasSchema } from '@/schemas/protocols/stream';
// Inbound certificates must follow the selected editor mode. The shared wire
// union also serves outbound TLS, where a client certificate is optional.
const InboundTlsCertFieldsSchema = TlsCertInlineSchema.extend({
useFile: z.boolean().optional(),
certificateFile: z.string().default(''),
keyFile: z.string().default(''),
certificate: z.array(z.string()).default([]),
key: z.array(z.string()).default([]),
});
const InboundTlsCertSchema = InboundTlsCertFieldsSchema.superRefine((cert, ctx) => {
const useFile = tlsCertUsesFiles(cert);
const hasCertificate = useFile
? cert.certificateFile.trim() !== ''
: cert.certificate.some((line) => line.trim() !== '');
const hasKey = useFile ? cert.keyFile.trim() !== '' : cert.key.some((line) => line.trim() !== '');
if (!hasCertificate) {
ctx.addIssue({
code: 'custom',
path: [useFile ? 'certificateFile' : 'certificate'],
message: 'pages.inbounds.form.tlsCertificateRequired',
});
}
if (cert.usage !== 'verify' && !hasKey) {
ctx.addIssue({
code: 'custom',
path: [useFile ? 'keyFile' : 'key'],
message: 'pages.inbounds.form.tlsPrivateKeyRequired',
});
}
}).transform((cert) => {
const { useFile: _useFile, certificateFile, keyFile, certificate, key, ...settings } = cert;
return tlsCertUsesFiles(cert)
? { ...settings, certificateFile, keyFile }
: { ...settings, certificate, key };
});
const InboundTlsSettingsSchema = TlsStreamSettingsSchema.extend({
certificates: z
.array(InboundTlsCertSchema)
.default([])
.refine((certificates) => certificates.some((cert) => cert.usage !== 'verify'), {
error: 'pages.inbounds.form.tlsServerCertificateRequired',
}),
});
const InboundSecuritySettingsSchema = securitySettingsSchemaFor(InboundTlsSettingsSchema);
export const InboundStreamFormSchema = NetworkSettingsSchema.and(InboundSecuritySettingsSchema).and(
StreamExtrasSchema,
);
export type InboundStreamFormValues = z.infer<typeof InboundStreamFormSchema>;
export const TrafficResetSchema = z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']);
export type TrafficReset = z.infer<typeof TrafficResetSchema>;
export const ShareAddrStrategySchema = z.enum(['node', 'listen', 'custom']);
export type ShareAddrStrategy = z.infer<typeof ShareAddrStrategySchema>;
// Db-side fields layered on top of the xray slice. These mirror the
// DBInbound model — they live in the SQL row, not in xray's config.
export const InboundDbFieldsSchema = z.object({
up: z.number().int().min(0).default(0),
down: z.number().int().min(0).default(0),
total: z.number().int().min(0).default(0),
trafficReset: TrafficResetSchema.default('never'),
trafficResetDay: z.number().int().min(1).max(31).default(1),
lastTrafficResetTime: z.number().int().default(0),
nodeId: z.number().int().nullable().optional(),
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>;
export const InboundFormBaseSchema = z.object({
remark: z.string().default(''),
enable: z.boolean().default(true),
port: InboundPortSchema,
listen: z.string().default(''),
tag: z.string().default(''),
expiryTime: z.number().int().default(0),
clientStats: z.string().optional(),
sniffing: SniffingSchema.default({
enabled: false,
destOverride: ['http', 'tls', 'quic', 'fakedns'],
metadataOnly: false,
routeOnly: false,
ipsExcluded: [],
domainsExcluded: [],
}),
streamSettings: InboundStreamFormSchema.optional(),
});
export type InboundFormBase = z.infer<typeof InboundFormBaseSchema>;
// Full form values = base + db fields + protocol-discriminated settings.
// Consumers narrow on `.protocol` to access the matching settings branch.
export const InboundFormSchema =
InboundFormBaseSchema.and(InboundDbFieldsSchema).and(InboundSettingsSchema);
export type InboundFormValues = z.infer<typeof InboundFormSchema>;
export const FallbackRowSchema = z.object({
rowKey: z.string(),
childId: z.number().int().nullable(),
name: z.string().default(''),
alpn: z.string().default(''),
path: z.string().default(''),
dest: z.string().default(''),
xver: z.number().int().min(0).max(2).default(0),
});
export type FallbackRow = z.infer<typeof FallbackRowSchema>;