Files
3x-ui/frontend/src/test/format-validation-error.test.ts
T
DuQi 47d2303334 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.
2026-09-08 18:08:24 +02:00

82 lines
2.8 KiB
TypeScript

/// <reference types="vite/client" />
import { describe, expect, it } from 'vitest';
import { z } from 'zod';
import type { TFunction } from 'i18next';
import {
formatInboundIssue,
formatInboundValidation,
} from '@/pages/inbounds/form/formatValidationError';
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',
};
const t = ((key: string, opts?: Record<string, unknown>) => {
let out = templates[key] ?? (opts?.defaultValue as string | undefined) ?? key;
if (opts) {
for (const [k, v] of Object.entries(opts)) {
out = out.split(`{${k}}`).join(String(v));
}
}
return out;
}) as unknown as TFunction;
describe('formatInboundValidation', () => {
it('resolves a real client array index back to the client email', () => {
const schema = z.object({
settings: z.object({
clients: z.array(z.object({ email: z.string(), tgId: z.number() })),
}),
});
const values = {
settings: {
clients: [
{ email: 'first@x.com', tgId: 1 },
{ email: 'broken@x.com', tgId: 'oops' },
],
},
};
const parsed = schema.safeParse(values);
expect(parsed.success).toBe(false);
if (parsed.success) return;
expect(formatInboundIssue(parsed.error.issues[0], values, t)).toContain(
'Client "broken@x.com": tgId — ',
);
});
it('falls back to the index when the client has no email', () => {
const issue = { path: ['settings', 'clients', 7, 'tgId'], message: 'Invalid input' };
const values = { settings: { clients: [] } };
expect(formatInboundIssue(issue, values, t)).toBe('Client #7: tgId — Invalid input');
});
it('formats non-client paths plainly', () => {
const issue = { path: ['port'], message: 'Invalid input' };
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' },
{ path: ['port'], message: 'Invalid input' },
];
const values = { settings: { clients: [{ email: 'a@x.com' }] } };
expect(formatInboundValidation(issues, values, t)).toBe(
'Client "a@x.com": tgId — Invalid input (+1 more)',
);
});
});