fix(inbounds): reject missing TLS certificates before saving (#6429)

An inbound could be saved with security "tls" and a certificate row carrying
neither a file path nor inline content. Nothing rejected it, so the row reached
xray-core, whose readFileOrString fails with "both file and bytes are empty"
and takes the whole config build down with it — every other inbound included.

Validate the credentials on both sides of the wire. validateInboundTLSCertificates
follows xray's file-over-inline precedence, requires a private key for every
non-verify certificate and insists on at least one server certificate, so a
verify-only CA list no longer passes as a server config. The inbound form's Zod
schema enforces the same rules per field and serializes only the editor mode the
operator actually used, and a failed save jumps to the Security tab naming the
certificate row that broke.

On update the guard is scoped to a real TLS edit. A row already stored
incomplete is grandfathered: it stays editable, and only a save that breaks a
previously valid block is refused.

A sub-node stores whatever the master pushes, and Remote.UpdateInbound falls
back to AddInbound when the node does not yet hold the tag, so a grandfathered
row could otherwise never be deployed or re-seeded — the rejection is swallowed
to a logger.Debug line and the node stays on a stale config while the panel
shows the client as cut off. The controller now marks a node-sync request (mTLS
or a node-sync token) on a per-request copy of InboundService, and the guard
steps aside for it on both add and update: the row was judged where the
operator acted, and a node that refuses it only falls out of sync. Operator and
admin-token saves are held to the guard as before.

The security union is parameterised on its tlsSettings branch instead of copied,
and tlsCertUsesFiles is the one file-vs-inline inference shared by the form
schema and the adapter, so the mode the editor opens in and the pair of fields
the save serializes cannot drift apart.
This commit is contained in:
DuQi
2026-09-08 17:47:12 +02:00
committed by Sanaei
parent 9f76a66dcf
commit 47d2303334
28 changed files with 697 additions and 23 deletions
@@ -19,6 +19,7 @@ import type { Sniffing } from '@/schemas/primitives';
import type { z } from 'zod';
import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
import { canEnableSniffing } from '@/lib/xray/protocol-capabilities';
import { tlsCertUsesFiles } from '@/schemas/protocols/security/tls';
import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
import { XHttpStreamSettingsSchema, XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
@@ -152,14 +153,7 @@ function tlsCerts(stream: Record<string, unknown>): Record<string, unknown>[] {
}
function synthesizeTlsCertUseFile(stream: Record<string, unknown>): void {
for (const c of tlsCerts(stream)) {
if (typeof c.useFile === 'boolean') continue;
const hasFile = !!c.certificateFile || !!c.keyFile;
const hasInline =
(Array.isArray(c.certificate) && c.certificate.length > 0) ||
(Array.isArray(c.key) && c.key.length > 0);
c.useFile = hasFile || !hasInline;
}
for (const c of tlsCerts(stream)) c.useFile = tlsCertUsesFiles(c);
}
function stripTlsCertUseFile(stream: Record<string, unknown>): void {
@@ -565,6 +565,7 @@ export default function InboundFormModal({
const parsed = InboundFormSchema.safeParse(values);
if (!parsed.success) {
const issues = parsed.error.issues;
setActiveTab(tabForValidationPath(issues[0].path));
messageApi.error(formatInboundValidation(issues, values, t));
console.error(
'[InboundFormModal] schema validation failed:',
@@ -18,6 +18,12 @@ export function formatInboundIssue(issue: IssueLike, values: unknown, t: TFuncti
const path = Array.isArray(issue?.path) ? issue.path : [];
const reason = t(issue?.message, { defaultValue: issue?.message });
if (path[0] === 'streamSettings' && path[1] === 'tlsSettings' && path[2] === 'certificates') {
return typeof path[3] === 'number'
? t('pages.inbounds.toasts.invalidCertificate', { index: path[3] + 1, reason })
: reason;
}
if (path[0] === 'settings' && path[1] === 'clients' && typeof path[2] === 'number') {
const index = path[2];
const clients = (values as { settings?: { clients?: ClientLike[] } })?.settings?.clients;
+57 -3
View File
@@ -2,11 +2,65 @@ import { z } from 'zod';
import { InboundPortSchema, SniffingSchema } from '@/schemas/primitives';
import { InboundSettingsSchema } from '@/schemas/protocols/inbound';
import { SecuritySettingsSchema } from '@/schemas/protocols/security';
import {
TlsCertInlineSchema,
TlsStreamSettingsSchema,
securitySettingsSchemaFor,
tlsCertUsesFiles,
} from '@/schemas/protocols/security';
import { NetworkSettingsSchema, StreamExtrasSchema } from '@/schemas/protocols/stream';
export const InboundStreamFormSchema =
NetworkSettingsSchema.and(SecuritySettingsSchema).and(StreamExtrasSchema);
// 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'), {
message: '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']);
@@ -21,12 +21,17 @@ export type Security = z.infer<typeof SecuritySchema>;
// transportless branch accepts that shape, mirroring NetworkSettingsSchema's
// `network: never().optional()` handling. A present-but-invalid security
// still fails both branches so a typo can't slip through.
export const SecuritySettingsSchema = z.union([
z.discriminatedUnion('security', [
z.object({ security: z.literal('none') }),
z.object({ security: z.literal('tls'), tlsSettings: TlsStreamSettingsSchema }),
z.object({ security: z.literal('reality'), realitySettings: RealityStreamSettingsSchema }),
]),
z.object({ security: z.never().optional() }),
]);
// The inbound form swaps in a stricter tlsSettings; every other branch is shared.
export function securitySettingsSchemaFor<T extends z.ZodType>(tlsSettings: T) {
return z.union([
z.discriminatedUnion('security', [
z.object({ security: z.literal('none') }),
z.object({ security: z.literal('tls'), tlsSettings }),
z.object({ security: z.literal('reality'), realitySettings: RealityStreamSettingsSchema }),
]),
z.object({ security: z.never().optional() }),
]);
}
export const SecuritySettingsSchema = securitySettingsSchemaFor(TlsStreamSettingsSchema);
export type SecuritySettings = z.infer<typeof SecuritySettingsSchema>;
+24 -1
View File
@@ -52,9 +52,32 @@ export const TlsCertInlineSchema = z.object({
usage: TlsCertUsageSchema.default('encipherment'),
buildChain: z.boolean().default(false),
});
export const TlsCertSchema = z.union([TlsCertFileSchema, TlsCertInlineSchema]);
export const TlsCertSchema = z.union([
TlsCertFileSchema,
TlsCertInlineSchema,
// Verification CAs contain only public certificates. Their omitted private
// keys must survive reading a saved inbound for details and share links.
TlsCertFileSchema.extend({ usage: z.literal('verify'), keyFile: z.string().optional() }),
TlsCertInlineSchema.extend({ usage: z.literal('verify'), key: z.array(z.string()).optional() }),
]);
export type TlsCert = z.infer<typeof TlsCertSchema>;
// A stored certificate predates the panel's `useFile` toggle when the boolean is
// absent; infer the editor mode from whichever half of the credential is filled.
export function tlsCertUsesFiles(cert: {
useFile?: unknown;
certificateFile?: unknown;
keyFile?: unknown;
certificate?: unknown;
key?: unknown;
}): boolean {
if (typeof cert.useFile === 'boolean') return cert.useFile;
const hasInline =
(Array.isArray(cert.certificate) && cert.certificate.length > 0) ||
(Array.isArray(cert.key) && cert.key.length > 0);
return !!cert.certificateFile || !!cert.keyFile || !hasInline;
}
export const TlsClientSettingsSchema = z.object({
// '' = None. Hysteria rejects uTLS fingerprints, and a chrome default
// silently flipped the form's None back to chrome on every save.
@@ -12,6 +12,7 @@ const templates: Record<string, string> = {
'pages.inbounds.toasts.invalidClientField': 'Client {client}: {field} — {reason}',
'pages.inbounds.toasts.invalidField': '{field} — {reason}',
'pages.inbounds.toasts.moreIssues': '{message} (+{count} more)',
'pages.inbounds.toasts.invalidCertificate': 'TLS certificate {index}: {reason}',
clients: 'clients',
};
@@ -59,6 +60,14 @@ describe('formatInboundValidation', () => {
expect(formatInboundIssue(issue, {}, t)).toBe('port — Invalid input');
});
it('identifies the certificate by its displayed row number', () => {
const issue = {
path: ['streamSettings', 'tlsSettings', 'certificates', 1, 'keyFile'],
message: 'Private key is required',
};
expect(formatInboundIssue(issue, {}, t)).toBe('TLS certificate 2: Private key is required');
});
it('appends a count when several fields fail', () => {
const issues = [
{ path: ['settings', 'clients', 0, 'tgId'], message: 'Invalid input' },
@@ -275,6 +275,28 @@ describe('InboundFormModal', () => {
expect(post).not.toHaveBeenCalled();
});
it('blocks adding TLS without a certificate and directs the user to Security', async () => {
const post = vi.mocked(HttpUtil.post);
post.mockClear();
messageError.mockClear();
renderModal();
fireEvent.click(screen.getByRole('tab', { name: 'Security' }));
fireEvent.click(screen.getByRole('radio', { name: 'TLS' }));
fireEvent.click(screen.getByRole('tab', { name: 'Basics' }));
fireEvent.click(primaryButton());
await waitFor(() => {
expect(screen.getByRole('tab', { name: 'Security' }).getAttribute('aria-selected')).toBe(
'true',
);
expect(messageError).toHaveBeenCalledWith(
expect.stringContaining('TLS certificate 1: Import a TLS certificate'),
);
});
expect(post).not.toHaveBeenCalled();
});
it('submits a valid clone-like Reality inbound', async () => {
const post = vi.mocked(HttpUtil.post);
post.mockClear();
@@ -0,0 +1,145 @@
import { describe, expect, it } from 'vitest';
import { InboundFormSchema, InboundStreamFormSchema } from '@/schemas/forms/inbound-form';
import { TlsCertSchema, TlsStreamSettingsSchema } from '@/schemas/protocols/security';
import { createTlsSettingsWithDefaultCert } from '@/lib/xray/inbound-tls-defaults';
import { formValuesToWirePayload } from '@/lib/xray/inbound-form-adapter';
import { inboundFromDb } from '@/lib/xray/inbound-from-db';
const fileCert = { certificateFile: '/cert/server.pem', keyFile: '/cert/server.key' };
const inlineCert = { certificate: ['certificate content'], key: ['private key content'] };
function parseCertificates(certificates?: unknown[]) {
return InboundFormSchema.safeParse({
port: 443,
protocol: 'vless',
settings: { clients: [] },
streamSettings: {
network: 'tcp',
tcpSettings: {},
security: 'tls',
tlsSettings: { certificates },
},
});
}
describe('inbound TLS certificate validation', () => {
it('rejects the empty certificate seeded by the TLS editor with a useful field error', () => {
const result = parseCertificates(createTlsSettingsWithDefaultCert().certificates as unknown[]);
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error.issues[0]).toMatchObject({
path: ['streamSettings', 'tlsSettings', 'certificates', 0, 'certificateFile'],
message: 'pages.inbounds.form.tlsCertificateRequired',
});
});
it.each([
['missing certificates', undefined],
['empty certificates', []],
['empty row', [{}]],
['blank paths', [{ certificateFile: ' ', keyFile: '\t' }]],
['certificate path only', [{ certificateFile: fileCert.certificateFile }]],
['private key path only', [{ keyFile: fileCert.keyFile }]],
['empty content', [{ useFile: false, certificate: [], key: [] }]],
['blank content', [{ useFile: false, certificate: [' ', '\n'], key: ['\t'] }]],
['certificate content only', [{ useFile: false, certificate: inlineCert.certificate }]],
['private key content only', [{ useFile: false, key: inlineCert.key }]],
['empty file mode with stale inline content', [{ useFile: true, ...inlineCert }]],
['empty content mode with stale file paths', [{ useFile: false, ...fileCert }]],
['valid certificate followed by an empty row', [fileCert, {}]],
['verify certificate only', [{ certificateFile: '/ca.pem', usage: 'verify' }]],
['issue certificate without its key', [{ certificate: ['CA'], usage: 'issue' }]],
['empty verify certificate alongside server certificate', [fileCert, { usage: 'verify' }]],
])('rejects %s', (_name, certificates) => {
expect(parseCertificates(certificates as unknown[] | undefined).success).toBe(false);
});
it.each([
['file certificate', [fileCert]],
['inline certificate', [inlineCert]],
['explicit file mode', [{ useFile: true, ...fileCert }]],
['explicit inline mode', [{ useFile: false, ...inlineCert }]],
['multiple certificates', [fileCert, inlineCert]],
['issuing CA with its key', [{ ...inlineCert, usage: 'issue' }]],
[
'file verification CA without a key',
[fileCert, { certificateFile: '/ca.pem', usage: 'verify' }],
],
[
'inline verification CA without a key',
[inlineCert, { certificate: ['CA'], usage: 'verify' }],
],
])('accepts %s', (_name, certificates) => {
expect(parseCertificates(certificates).success).toBe(true);
});
it.each([true, false])('serializes only the selected mode (useFile=%s)', (useFile) => {
const result = parseCertificates([{ useFile, ...fileCert, ...inlineCert }]);
expect(result.success).toBe(true);
if (!result.success) return;
const stream = JSON.parse(formValuesToWirePayload(result.data).streamSettings);
const cert = stream.tlsSettings.certificates[0];
expect(cert).toMatchObject(useFile ? fileCert : inlineCert);
expect(cert).not.toHaveProperty('useFile');
expect(cert).not.toHaveProperty(useFile ? 'certificate' : 'certificateFile');
expect(cert).not.toHaveProperty(useFile ? 'key' : 'keyFile');
});
it.each([
['file', { certificateFile: '/cert/ca.pem', usage: 'verify' }],
['inline', { certificate: ['CA certificate'], usage: 'verify' }],
])('preserves TLS settings when reading back a %s verification CA without a key', (_mode, ca) => {
const tlsSettings = {
serverName: 'tls.example.test',
alpn: ['h3'],
certificates: [fileCert, ca],
settings: { fingerprint: 'firefox', pinnedPeerCertSha256: ['test-pin'] },
};
const values = InboundFormSchema.parse({
port: 443,
protocol: 'vless',
settings: { clients: [] },
streamSettings: { network: 'tcp', tcpSettings: {}, security: 'tls', tlsSettings },
});
const restored = inboundFromDb(formValuesToWirePayload(values));
expect(restored.streamSettings).toMatchObject({ security: 'tls', tlsSettings });
});
it.each([undefined, 'encipherment', 'issue'])(
'keeps wire private keys required for usage=%s',
(usage) => {
expect(TlsCertSchema.safeParse({ certificateFile: '/cert.pem', usage }).success).toBe(false);
expect(
TlsCertSchema.safeParse({ certificateFile: '/cert.pem', keyFile: '', usage }).success,
).toBe(false);
expect(TlsCertSchema.safeParse({ certificate: ['certificate'], usage }).success).toBe(false);
},
);
it('applies the same certificate requirement to Hysteria TLS', () => {
const stream = {
network: 'hysteria',
hysteriaSettings: {},
security: 'tls',
tlsSettings: createTlsSettingsWithDefaultCert(),
};
expect(InboundStreamFormSchema.safeParse(stream).success).toBe(false);
expect(
InboundStreamFormSchema.safeParse({ ...stream, tlsSettings: { certificates: [fileCert] } })
.success,
).toBe(true);
});
it('keeps Reality, unsecured, transportless and outbound TLS certificate-free', () => {
for (const security of [{ security: 'reality', realitySettings: {} }, { security: 'none' }]) {
expect(
InboundStreamFormSchema.safeParse({ network: 'tcp', tcpSettings: {}, ...security }).success,
).toBe(true);
}
expect(InboundStreamFormSchema.safeParse({}).success).toBe(true);
expect(TlsStreamSettingsSchema.safeParse({}).success).toBe(true);
});
});
@@ -488,6 +488,7 @@ describe('inbound formValuesToWirePayload integration', () => {
},
tlsSettings: {
alpn: ['h3'],
certificates: [{ certificateFile: '/cert/server.pem', keyFile: '/cert/server.key' }],
settings: {
fingerprint: '',
},