1
0
Эх сурвалжийг харах

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.
DuQi 3 цаг өмнө
parent
commit
47d2303334
28 өөрчлөгдсөн 697 нэмэгдсэн , 23 устгасан
  1. 2 8
      frontend/src/lib/xray/inbound-form-adapter.ts
  2. 1 0
      frontend/src/pages/inbounds/form/InboundFormModal.tsx
  3. 6 0
      frontend/src/pages/inbounds/form/formatValidationError.ts
  4. 57 3
      frontend/src/schemas/forms/inbound-form.ts
  5. 13 8
      frontend/src/schemas/protocols/security/index.ts
  6. 24 1
      frontend/src/schemas/protocols/security/tls.ts
  7. 9 0
      frontend/src/test/format-validation-error.test.ts
  8. 22 0
      frontend/src/test/inbound-form-modal.test.tsx
  9. 145 0
      frontend/src/test/inbound-tls-validation.test.ts
  10. 1 0
      frontend/src/test/stream-wire-normalize.test.ts
  11. 11 2
      internal/web/controller/inbound.go
  12. 93 0
      internal/web/controller/inbound_node_sync_test.go
  13. 75 0
      internal/web/service/inbound.go
  14. 1 1
      internal/web/service/inbound_durable_postgres_test.go
  15. 185 0
      internal/web/service/inbound_tls_test.go
  16. 4 0
      internal/web/translation/ar-EG.json
  17. 4 0
      internal/web/translation/en-US.json
  18. 4 0
      internal/web/translation/es-ES.json
  19. 4 0
      internal/web/translation/fa-IR.json
  20. 4 0
      internal/web/translation/id-ID.json
  21. 4 0
      internal/web/translation/ja-JP.json
  22. 4 0
      internal/web/translation/pt-BR.json
  23. 4 0
      internal/web/translation/ru-RU.json
  24. 4 0
      internal/web/translation/tr-TR.json
  25. 4 0
      internal/web/translation/uk-UA.json
  26. 4 0
      internal/web/translation/vi-VN.json
  27. 4 0
      internal/web/translation/zh-CN.json
  28. 4 0
      internal/web/translation/zh-TW.json

+ 2 - 8
frontend/src/lib/xray/inbound-form-adapter.ts

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

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

@@ -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:',

+ 6 - 0
frontend/src/pages/inbounds/form/formatValidationError.ts

@@ -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
frontend/src/schemas/forms/inbound-form.ts

@@ -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']);

+ 13 - 8
frontend/src/schemas/protocols/security/index.ts

@@ -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
frontend/src/schemas/protocols/security/tls.ts

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

+ 9 - 0
frontend/src/test/format-validation-error.test.ts

@@ -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' },

+ 22 - 0
frontend/src/test/inbound-form-modal.test.tsx

@@ -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();

+ 145 - 0
frontend/src/test/inbound-tls-validation.test.ts

@@ -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);
+  });
+});

+ 1 - 0
frontend/src/test/stream-wire-normalize.test.ts

@@ -488,6 +488,7 @@ describe('inbound formValuesToWirePayload integration', () => {
         },
         tlsSettings: {
           alpn: ['h3'],
+          certificates: [{ certificateFile: '/cert/server.pem', keyFile: '/cert/server.key' }],
           settings: {
             fingerprint: '',
           },

+ 11 - 2
internal/web/controller/inbound.go

@@ -59,6 +59,15 @@ func (a *InboundController) broadcastInboundsUpdate(userId int) {
 	websocket.BroadcastInbounds(inbounds)
 }
 
+// inboundServiceFor tells the service whether this request is a master's
+// node-sync push, so the node stores the row instead of re-judging it.
+func (a *InboundController) inboundServiceFor(c *gin.Context) *service.InboundService {
+	svc := a.inboundService
+	scope, _ := c.Get("api_token_scope")
+	svc.FromNodeSync = scope == model.ApiScopeNodeSync
+	return &svc
+}
+
 // initRouter initializes the routes for inbound-related operations.
 func (a *InboundController) initRouter(g *gin.RouterGroup) {
 	g.GET("/list", a.getInbounds)
@@ -162,7 +171,7 @@ func (a *InboundController) addInbound(c *gin.Context) {
 		inbound.NodeID = nil
 	}
 
-	inbound, needRestart, err := a.inboundService.AddInbound(inbound)
+	inbound, needRestart, err := a.inboundServiceFor(c).AddInbound(inbound)
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		return
@@ -242,7 +251,7 @@ func (a *InboundController) updateInbound(c *gin.Context) {
 	if inbound.NodeID != nil && *inbound.NodeID == 0 {
 		inbound.NodeID = nil
 	}
-	inbound, needRestart, err := a.inboundService.UpdateInbound(inbound)
+	inbound, needRestart, err := a.inboundServiceFor(c).UpdateInbound(inbound)
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		return

+ 93 - 0
internal/web/controller/inbound_node_sync_test.go

@@ -0,0 +1,93 @@
+package controller
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"testing"
+
+	"github.com/gin-gonic/gin"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
+)
+
+// A sub-node stores whatever the master pushes. A master row whose certificate
+// predates the TLS guard must still land, or the node silently falls out of sync.
+func TestNodeSyncPushSkipsOperatorTLSGuard(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+	prev := runtime.GetManager()
+	runtime.SetManager(runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}}))
+	t.Cleanup(func() { runtime.SetManager(prev) })
+
+	for name, scope := range map[string]string{"node-sync": model.ApiScopeNodeSync, "admin": model.ApiScopeAdmin} {
+		row := &model.ApiToken{Name: name, Token: crypto.HashTokenSHA256(name + "-token"), Enabled: true, Scope: scope}
+		if err := database.GetDB().Create(row).Error; err != nil {
+			t.Fatalf("seed %s token: %v", name, err)
+		}
+	}
+
+	engine := gin.New()
+	a := &APIController{}
+	api := engine.Group("/panel/api")
+	api.Use(a.checkAPIAuth, a.enforceTokenScope)
+	NewInboundController(api.Group("/inbounds"))
+
+	const legacyStream = `{"network":"tcp","security":"tls","tlsSettings":{"certificates":[{"certificateFile":"","keyFile":"","certificate":[],"key":[]}]}}`
+	add := func(t *testing.T, token string, port int) string {
+		t.Helper()
+		form := url.Values{
+			"protocol":       {"vless"},
+			"port":           {strconv.Itoa(port)},
+			"tag":            {"tls-legacy-" + strconv.Itoa(port)},
+			"enable":         {"true"},
+			"settings":       {`{"clients":[]}`},
+			"streamSettings": {legacyStream},
+		}
+		req := httptest.NewRequest(http.MethodPost, "/panel/api/inbounds/add", strings.NewReader(form.Encode()))
+		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+		req.Header.Set("Authorization", "Bearer "+token)
+		w := httptest.NewRecorder()
+		engine.ServeHTTP(w, req)
+		return w.Body.String()
+	}
+	rows := func(t *testing.T, tag string) int64 {
+		t.Helper()
+		var n int64
+		if err := database.GetDB().Model(&model.Inbound{}).Where("tag = ?", tag).Count(&n).Error; err != nil {
+			t.Fatalf("count %s: %v", tag, err)
+		}
+		return n
+	}
+
+	t.Run("a master push lands on the node", func(t *testing.T) {
+		body := add(t, "node-sync-token", 45001)
+		if !strings.Contains(body, `"success":true`) {
+			t.Fatalf("node-sync add rejected: %s", body)
+		}
+		if got := rows(t, "tls-legacy-45001"); got != 1 {
+			t.Fatalf("stored rows = %d, want 1", got)
+		}
+	})
+
+	t.Run("an operator token is still held to the guard", func(t *testing.T) {
+		body := add(t, "admin-token", 45002)
+		if !strings.Contains(body, `"success":false`) || !strings.Contains(body, "TLS") {
+			t.Fatalf("admin add should fail on TLS, got: %s", body)
+		}
+		if got := rows(t, "tls-legacy-45002"); got != 0 {
+			t.Fatalf("stored rows = %d, want 0", got)
+		}
+	})
+}

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

@@ -34,6 +34,9 @@ import (
 type InboundService struct {
 	clientService   ClientService
 	fallbackService FallbackService
+	// FromNodeSync marks a master push: the row was validated where the operator
+	// acted, and a node that refuses it only falls out of sync.
+	FromNodeSync bool
 }
 
 func normalizeTrafficResetDay(day int) int {
@@ -610,6 +613,64 @@ func canonicalizeStreamNetworkKey(streamSettings string) string {
 	return string(out)
 }
 
+// validateInboundTLSCertificates rejects incomplete TLS credentials before a save
+// can restart Xray. File paths belong to the node, so only presence is checked.
+func validateInboundTLSCertificates(streamSettings string) error {
+	if strings.TrimSpace(streamSettings) == "" {
+		return nil
+	}
+	var stream struct {
+		Security    string          `json:"security"`
+		TLSSettings json.RawMessage `json:"tlsSettings"`
+	}
+	if err := json.Unmarshal([]byte(streamSettings), &stream); err != nil {
+		return common.NewError("Invalid inbound stream settings: ", err)
+	}
+	if !strings.EqualFold(stream.Security, "tls") {
+		return nil
+	}
+	var settings struct {
+		Certificates []struct {
+			CertificateFile string   `json:"certificateFile"`
+			KeyFile         string   `json:"keyFile"`
+			Certificate     []string `json:"certificate"`
+			Key             []string `json:"key"`
+			Usage           string   `json:"usage"`
+		} `json:"certificates"`
+	}
+	if len(stream.TLSSettings) > 0 {
+		if err := json.Unmarshal(stream.TLSSettings, &settings); err != nil {
+			return common.NewError("Invalid inbound TLS settings: ", err)
+		}
+	}
+	hasServerCertificate := false
+	for i, cert := range settings.Certificates {
+		// Match Xray's file-over-inline precedence for each credential.
+		certificate := cert.CertificateFile
+		if certificate == "" {
+			certificate = strings.Join(cert.Certificate, "\n")
+		}
+		if strings.TrimSpace(certificate) == "" {
+			return common.NewErrorf("TLS certificate %d is missing. Configure a certificate file path or certificate content before saving the inbound.", i+1)
+		}
+		if strings.EqualFold(cert.Usage, "verify") {
+			continue
+		}
+		key := cert.KeyFile
+		if key == "" {
+			key = strings.Join(cert.Key, "\n")
+		}
+		if strings.TrimSpace(key) == "" {
+			return common.NewErrorf("TLS certificate %d is missing its private key. Configure a private key file path or private key content before saving the inbound.", i+1)
+		}
+		hasServerCertificate = true
+	}
+	if !hasServerCertificate {
+		return common.NewError("TLS requires a server certificate and private key. Configure an encipherment or issue certificate before saving the inbound.")
+	}
+	return nil
+}
+
 // finalMaskRealityTcpMasks returns the stream's finalmask.tcp masks when the
 // stream uses REALITY security, or nil otherwise. A non-empty result means
 // this stream carries the finalmask+REALITY combination that panics
@@ -968,6 +1029,11 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
 	inbound.TrafficResetDay = normalizeTrafficResetDay(inbound.TrafficResetDay)
 	// Normalize streamSettings based on protocol
 	s.normalizeStreamSettings(inbound)
+	if !s.FromNodeSync {
+		if err := validateInboundTLSCertificates(inbound.StreamSettings); err != nil {
+			return inbound, false, err
+		}
+	}
 	if err := validateFinalMaskRealityCombo(inbound.StreamSettings); err != nil {
 		return inbound, false, err
 	}
@@ -1520,6 +1586,15 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 	if err != nil {
 		return inbound, false, err
 	}
+	// Grandfather a row that was already stored incomplete so it stays editable;
+	// only a save that breaks a previously valid TLS block is refused.
+	if !s.FromNodeSync {
+		if err := validateInboundTLSCertificates(inbound.StreamSettings); err != nil {
+			if validateInboundTLSCertificates(oldInbound.StreamSettings) == nil {
+				return inbound, false, err
+			}
+		}
+	}
 	// Restore the stored NodeID before the port-conflict check so a node inbound
 	// stays scoped to its own node (the payload's nodeId is unreliable, often absent).
 	inbound.NodeID = oldInbound.NodeID

+ 1 - 1
internal/web/service/inbound_durable_postgres_test.go

@@ -35,7 +35,7 @@ func durableTestInbound(nodeID *int, tag string, port int) *model.Inbound {
 		Enable:         true,
 		Port:           port,
 		Protocol:       model.VLESS,
-		StreamSettings: `{"network":"tcp","security":"tls"}`,
+		StreamSettings: `{"network":"tcp","security":"tls","tlsSettings":{"certificates":[{"certificateFile":"/node/cert.pem","keyFile":"/node/key.pem"}]}}`,
 		Settings:       `{"clients":[],"decryption":"none"}`,
 	}
 }

+ 185 - 0
internal/web/service/inbound_tls_test.go

@@ -0,0 +1,185 @@
+package service
+
+import (
+	"reflect"
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func TestValidateInboundTLSCertificates(t *testing.T) {
+	tests := []struct {
+		name           string
+		streamSettings string
+		wantErr        bool
+	}{
+		{"empty stream", "", false},
+		{"whitespace stream", " \t\n", false},
+		{"none ignores stale TLS settings", `{"security":"none","tlsSettings":{"certificates":[{}]}}`, false},
+		{"reality needs no TLS certificate", `{"security":"reality","realitySettings":{}}`, false},
+		{"missing TLS settings", `{"security":"tls"}`, true},
+		{"uppercase TLS security", `{"security":"TLS","tlsSettings":{}}`, true},
+		{"mixed-case TLS security", `{"security":"Tls","tlsSettings":{}}`, true},
+		{"null TLS settings", `{"security":"tls","tlsSettings":null}`, true},
+		{"missing certificates", `{"security":"tls","tlsSettings":{}}`, true},
+		{"null certificates", `{"security":"tls","tlsSettings":{"certificates":null}}`, true},
+		{"empty certificates", `{"security":"tls","tlsSettings":{"certificates":[]}}`, true},
+		{"null certificate row", `{"security":"tls","tlsSettings":{"certificates":[null]}}`, true},
+		{"empty default file fields", `{"security":"tls","tlsSettings":{"certificates":[{"certificateFile":"","keyFile":""}]}}`, true},
+		{"empty default inline fields", `{"security":"tls","tlsSettings":{"certificates":[{"certificate":[],"key":[]}]}}`, true},
+		{"whitespace file fields", `{"security":"tls","tlsSettings":{"certificates":[{"certificateFile":" \t","keyFile":" \n"}]}}`, true},
+		{"whitespace inline certificate", `{"security":"tls","tlsSettings":{"certificates":[{"certificate":[" ","\t"],"key":["private key"]}]}}`, true},
+		{"whitespace inline key", `{"security":"tls","tlsSettings":{"certificates":[{"certificate":["certificate"],"key":[" ","\n"]}]}}`, true},
+		{"missing private key", `{"security":"tls","tlsSettings":{"certificates":[{"certificateFile":"/node/cert.pem"}]}}`, true},
+		{"missing certificate", `{"security":"tls","tlsSettings":{"certificates":[{"keyFile":"/node/key.pem"}]}}`, true},
+		{"verify only", `{"security":"tls","tlsSettings":{"certificates":[{"usage":"verify","certificateFile":"/node/ca.pem"}]}}`, true},
+		{"verify with private key still needs server certificate", `{"security":"tls","tlsSettings":{"certificates":[{"usage":"verify","certificateFile":"/node/ca.pem","keyFile":"/node/key.pem"}]}}`, true},
+		{"issue needs private key", `{"security":"tls","tlsSettings":{"certificates":[{"usage":"issue","certificateFile":"/node/ca.pem"}]}}`, true},
+		{"file credentials with default usage", `{"security":"tls","tlsSettings":{"certificates":[{"certificateFile":"/node/cert.pem","keyFile":"/node/key.pem"}]}}`, false},
+		{"inline credentials", `{"security":"tls","tlsSettings":{"certificates":[{"certificate":["certificate"],"key":["private key"]}]}}`, false},
+		{"certificate file and inline key", `{"security":"tls","tlsSettings":{"certificates":[{"certificateFile":"/node/cert.pem","key":["private key"]}]}}`, false},
+		{"inline certificate and key file", `{"security":"tls","tlsSettings":{"certificates":[{"certificate":["certificate"],"keyFile":"/node/key.pem"}]}}`, false},
+		{"encipherment usage", `{"security":"tls","tlsSettings":{"certificates":[{"usage":"encipherment","certificateFile":"/node/cert.pem","keyFile":"/node/key.pem"}]}}`, false},
+		{"issue usage", `{"security":"tls","tlsSettings":{"certificates":[{"usage":"issue","certificateFile":"/node/ca.pem","keyFile":"/node/ca-key.pem"}]}}`, false},
+		{"unknown usage defaults to encipherment like Xray", `{"security":"tls","tlsSettings":{"certificates":[{"usage":"custom","certificateFile":"/node/cert.pem","keyFile":"/node/key.pem"}]}}`, false},
+		{"verify and server certificates", `{"security":"tls","tlsSettings":{"certificates":[{"usage":"verify","certificateFile":"/node/ca.pem"},{"certificateFile":"/node/cert.pem","keyFile":"/node/key.pem"}]}}`, false},
+		{"verify usage is case insensitive", `{"security":"tls","tlsSettings":{"certificates":[{"usage":"VERIFY","certificateFile":"/node/ca.pem"},{"certificateFile":"/node/cert.pem","keyFile":"/node/key.pem"}]}}`, false},
+		{"empty extra certificate row", `{"security":"tls","tlsSettings":{"certificates":[{"certificateFile":"/node/cert.pem","keyFile":"/node/key.pem"},{}]}}`, true},
+		{"empty extra verify certificate", `{"security":"tls","tlsSettings":{"certificates":[{"certificateFile":"/node/cert.pem","keyFile":"/node/key.pem"},{"usage":"verify"}]}}`, true},
+		{"whitespace certificate file overrides inline content", `{"security":"tls","tlsSettings":{"certificates":[{"certificateFile":" ","certificate":["certificate"],"key":["private key"]}]}}`, true},
+		{"whitespace key file overrides inline content", `{"security":"tls","tlsSettings":{"certificates":[{"certificate":["certificate"],"keyFile":" ","key":["private key"]}]}}`, true},
+		{"malformed stream", `{"security":"tls"`, true},
+		{"malformed TLS settings", `{"security":"tls","tlsSettings":"invalid"}`, true},
+		{"malformed certificate list", `{"security":"tls","tlsSettings":{"certificates":{}}}`, true},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := validateInboundTLSCertificates(tt.streamSettings)
+			if (err != nil) != tt.wantErr {
+				t.Fatalf("validateInboundTLSCertificates() error = %v, wantErr %v", err, tt.wantErr)
+			}
+		})
+	}
+}
+
+func TestValidateInboundTLSCertificatesIdentifiesIncompleteRow(t *testing.T) {
+	err := validateInboundTLSCertificates(`{"security":"tls","tlsSettings":{"certificates":[{"certificateFile":"/node/cert.pem","keyFile":"/node/key.pem"},{"certificateFile":"/node/other.pem"}]}}`)
+	if err == nil || !strings.Contains(err.Error(), "TLS certificate 2") || !strings.Contains(err.Error(), "private key") {
+		t.Fatalf("expected actionable error for the second certificate's private key, got %v", err)
+	}
+}
+
+func TestAddInboundRejectsMissingTLSCertificates(t *testing.T) {
+	setupConflictDB(t)
+	mgr := useTestRuntimeManager(t)
+	fake := &fakeNodeRuntime{}
+	mgr.SetLocalRuntimeOverride(fake)
+
+	inbound := &model.Inbound{
+		Tag:            "tls-missing-44310",
+		Enable:         true,
+		Listen:         "0.0.0.0",
+		Port:           44310,
+		Protocol:       model.VLESS,
+		StreamSettings: `{"network":"tcp","security":"tls","tlsSettings":{"certificates":[{"certificateFile":"","keyFile":""}]}}`,
+		Settings:       `{"clients":[]}`,
+	}
+	_, needRestart, err := (&InboundService{}).AddInbound(inbound)
+	if err == nil || !strings.Contains(err.Error(), "TLS") {
+		t.Fatalf("AddInbound: expected TLS validation error, got %v", err)
+	}
+	if needRestart {
+		t.Fatal("AddInbound: rejected TLS configuration requested a restart")
+	}
+	var count int64
+	if err := database.GetDB().Model(&model.Inbound{}).Count(&count).Error; err != nil {
+		t.Fatalf("count inbounds: %v", err)
+	}
+	if count != 0 {
+		t.Fatalf("AddInbound: rejected TLS configuration created %d rows", count)
+	}
+	if fake.addInbound.Load() != 0 || fake.updateInbound.Load() != 0 || fake.delInbound.Load() != 0 {
+		t.Fatal("AddInbound: rejected TLS configuration reached the runtime")
+	}
+}
+
+func TestUpdateInboundRejectsMissingTLSCertificates(t *testing.T) {
+	setupConflictDB(t)
+	mgr := useTestRuntimeManager(t)
+	fake := &fakeNodeRuntime{}
+	mgr.SetLocalRuntimeOverride(fake)
+
+	seedInboundConflict(t, "tls-existing-44311", "0.0.0.0", 44311, model.VLESS,
+		`{"network":"tcp","security":"tls","tlsSettings":{"certificates":[{"certificateFile":"/node/cert.pem","keyFile":"/node/key.pem"}]}}`, `{"clients":[]}`)
+	var existing model.Inbound
+	if err := database.GetDB().Where("tag = ?", "tls-existing-44311").First(&existing).Error; err != nil {
+		t.Fatalf("load existing inbound: %v", err)
+	}
+	update := existing
+	update.Remark = "must not be saved"
+	update.Port = 44312
+	update.StreamSettings = `{"network":"tcp","security":"tls","tlsSettings":{"certificates":[{"certificateFile":"/node/cert.pem"}]}}`
+	_, needRestart, err := (&InboundService{}).UpdateInbound(&update)
+	if err == nil || !strings.Contains(err.Error(), "TLS") {
+		t.Fatalf("UpdateInbound: expected TLS validation error, got %v", err)
+	}
+	if needRestart {
+		t.Fatal("UpdateInbound: rejected TLS configuration requested a restart")
+	}
+	var reloaded model.Inbound
+	if err := database.GetDB().First(&reloaded, existing.Id).Error; err != nil {
+		t.Fatalf("reload existing inbound: %v", err)
+	}
+	if !reflect.DeepEqual(reloaded, existing) {
+		t.Fatal("UpdateInbound: rejected TLS configuration changed the stored inbound")
+	}
+	if fake.addInbound.Load() != 0 || fake.updateInbound.Load() != 0 || fake.delInbound.Load() != 0 {
+		t.Fatal("UpdateInbound: rejected TLS configuration reached the runtime")
+	}
+}
+
+// The panel used to seed a TLS inbound with an all-empty certificate, so rows in
+// that shape predate the guard and must stay editable — see UpdateInbound.
+func TestUpdateInboundAllowsUntouchedLegacyTLSCertificates(t *testing.T) {
+	const legacyStream = `{"network":"tcp","security":"tls","tlsSettings":{"certificates":[{"certificateFile":"","keyFile":"","certificate":[],"key":[]}]}}`
+
+	tests := []struct {
+		name           string
+		streamSettings string
+	}{
+		{"remark-only edit resends the stored block", legacyStream},
+		{"node push re-encodes the same block", `{"network":"tcp","security":"tls","tlsSettings":{"certificates":[{"key":[],"certificate":[],"keyFile":"","certificateFile":""}]}}`},
+		{"a partial fix to the stored credentials is tolerated", `{"network":"tcp","security":"tls","tlsSettings":{"certificates":[{"certificateFile":"/node/cert.pem"}]}}`},
+		{"completing the credentials is accepted", `{"network":"tcp","security":"tls","tlsSettings":{"certificates":[{"certificateFile":"/node/cert.pem","keyFile":"/node/key.pem"}]}}`},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			setupConflictDB(t)
+			mgr := useTestRuntimeManager(t)
+			mgr.SetLocalRuntimeOverride(&fakeNodeRuntime{})
+
+			seedInboundConflict(t, "tls-legacy-44321", "0.0.0.0", 44321, model.VLESS, legacyStream, `{"clients":[]}`)
+			var existing model.Inbound
+			if err := database.GetDB().Where("tag = ?", "tls-legacy-44321").First(&existing).Error; err != nil {
+				t.Fatalf("load legacy inbound: %v", err)
+			}
+
+			update := existing
+			update.Remark = "renamed"
+			update.StreamSettings = tt.streamSettings
+			if _, _, err := (&InboundService{}).UpdateInbound(&update); err != nil {
+				t.Fatalf("UpdateInbound: %v", err)
+			}
+			var reloaded model.Inbound
+			if err := database.GetDB().First(&reloaded, existing.Id).Error; err != nil {
+				t.Fatalf("reload inbound: %v", err)
+			}
+			if reloaded.Remark != "renamed" {
+				t.Fatalf("UpdateInbound: remark = %q, want %q", reloaded.Remark, "renamed")
+			}
+		})
+	}
+}

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

@@ -453,6 +453,7 @@
         "scanRealityTargetPrivate": "الهدف يعمل لكنه في شبكة خاصة/محلية.",
         "invalidClientField": "العميل {client}: الحقل {field} — {reason}",
         "invalidField": "{field} — {reason}",
+        "invalidCertificate": "شهادة TLS رقم {index}: {reason}",
         "moreIssues": "{message}  (+{count} أخرى)"
       },
       "form": {
@@ -610,6 +611,9 @@
         "shortIds": "Short IDs",
         "realityTargetHint": "مطلوب. يجب أن يتضمّن منفذًا (مثل example.com:443). بدون منفذ يرفض Xray-core البدء.",
         "realityTargetRequired": "هدف REALITY مطلوب",
+        "tlsCertificateRequired": "استورد شهادة TLS أو أدخل مسار ملفها قبل الحفظ",
+        "tlsPrivateKeyRequired": "استورد مفتاح TLS الخاص أو أدخل مسار ملفه قبل الحفظ",
+        "tlsServerCertificateRequired": "يتطلب TLS شهادة خادم واحدة على الأقل مع مفتاحها الخاص (encipherment أو issue)",
         "realityTargetNeedsPort": "يجب أن يتضمّن هدف REALITY منفذًا (مثل example.com:443)",
         "realityTargetInvalidPort": "هدف REALITY يحتوي على منفذ غير صالح",
         "scan": "فحص",

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

@@ -453,6 +453,7 @@
         "scanRealityTargetPrivate": "Target is reachable but sits on a private/local network.",
         "invalidClientField": "Client {client}: {field} — {reason}",
         "invalidField": "{field} — {reason}",
+        "invalidCertificate": "TLS certificate {index}: {reason}",
         "moreIssues": "{message}  (+{count} more)"
       },
       "form": {
@@ -622,6 +623,9 @@
         "shortIds": "Short IDs",
         "realityTargetHint": "Required. Must include a port (e.g. example.com:443). Without a port Xray-core refuses to start.",
         "realityTargetRequired": "REALITY target is required",
+        "tlsCertificateRequired": "Import a TLS certificate or enter its file path before saving",
+        "tlsPrivateKeyRequired": "Import the TLS private key or enter its file path before saving",
+        "tlsServerCertificateRequired": "TLS requires at least one server certificate with its private key (encipherment or issue)",
         "realityTargetNeedsPort": "REALITY target must include a port (e.g. example.com:443)",
         "realityTargetInvalidPort": "REALITY target has an invalid port",
         "scan": "Scan",

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

@@ -453,6 +453,7 @@
         "scanRealityTargetPrivate": "El destino funciona, pero está en una red privada/local.",
         "invalidClientField": "Cliente {client}: campo {field} — {reason}",
         "invalidField": "{field} — {reason}",
+        "invalidCertificate": "Certificado TLS {index}: {reason}",
         "moreIssues": "{message}  (+{count} más)"
       },
       "form": {
@@ -631,6 +632,9 @@
         "shortIds": "Short IDs",
         "realityTargetHint": "Obligatorio. Debe incluir un puerto (p. ej. example.com:443). Sin puerto, Xray-core no arranca.",
         "realityTargetRequired": "El destino REALITY es obligatorio",
+        "tlsCertificateRequired": "Importe un certificado TLS o introduzca la ruta de su archivo antes de guardar",
+        "tlsPrivateKeyRequired": "Importe la clave privada TLS o introduzca la ruta de su archivo antes de guardar",
+        "tlsServerCertificateRequired": "TLS requiere al menos un certificado de servidor con su clave privada (encipherment o issue)",
         "realityTargetNeedsPort": "El destino REALITY debe incluir un puerto (p. ej. example.com:443)",
         "realityTargetInvalidPort": "El destino REALITY tiene un puerto no válido",
         "scan": "Escanear",

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

@@ -453,6 +453,7 @@
         "scanRealityTargetPrivate": "هدف کار می‌کند اما در شبکهٔ خصوصی/محلی قرار دارد.",
         "invalidClientField": "کلاینت {client}: فیلد {field} — {reason}",
         "invalidField": "{field} — {reason}",
+        "invalidCertificate": "گواهی TLS شماره {index}: {reason}",
         "moreIssues": "{message}  (+{count} مورد دیگر)"
       },
       "form": {
@@ -622,6 +623,9 @@
         "shortIds": "Short IDها",
         "realityTargetHint": "الزامی است. باید شامل پورت باشد (مثلاً example.com:443). بدون پورت، Xray-core اجرا نمی‌شود.",
         "realityTargetRequired": "هدف REALITY الزامی است",
+        "tlsCertificateRequired": "پیش از ذخیره، گواهی TLS را وارد کنید یا مسیر فایل آن را بنویسید",
+        "tlsPrivateKeyRequired": "پیش از ذخیره، کلید خصوصی TLS را وارد کنید یا مسیر فایل آن را بنویسید",
+        "tlsServerCertificateRequired": "TLS به حداقل یک گواهی سرور همراه با کلید خصوصی آن نیاز دارد (encipherment یا issue)",
         "realityTargetNeedsPort": "هدف REALITY باید شامل پورت باشد (مثلاً example.com:443)",
         "realityTargetInvalidPort": "پورت هدف REALITY نامعتبر است",
         "scan": "اسکن",

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

@@ -453,6 +453,7 @@
         "scanRealityTargetPrivate": "Target dapat dijangkau, tetapi berada di jaringan privat/lokal.",
         "invalidClientField": "Klien {client}: kolom {field} — {reason}",
         "invalidField": "{field} — {reason}",
+        "invalidCertificate": "Sertifikat TLS {index}: {reason}",
         "moreIssues": "{message}  (+{count} lainnya)"
       },
       "form": {
@@ -610,6 +611,9 @@
         "shortIds": "Short IDs",
         "realityTargetHint": "Wajib. Harus menyertakan port (mis. example.com:443). Tanpa port, Xray-core menolak untuk mulai.",
         "realityTargetRequired": "Target REALITY wajib diisi",
+        "tlsCertificateRequired": "Impor sertifikat TLS atau masukkan jalur file sertifikat sebelum menyimpan",
+        "tlsPrivateKeyRequired": "Impor kunci privat TLS atau masukkan jalur file kunci privat sebelum menyimpan",
+        "tlsServerCertificateRequired": "TLS memerlukan setidaknya satu sertifikat server beserta kunci privatnya (encipherment atau issue)",
         "realityTargetNeedsPort": "Target REALITY harus menyertakan port (mis. example.com:443)",
         "realityTargetInvalidPort": "Target REALITY memiliki port yang tidak valid",
         "scan": "Pindai",

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

@@ -453,6 +453,7 @@
         "scanRealityTargetPrivate": "ターゲットは利用可能ですが、プライベート/ローカルネットワーク上にあります。",
         "invalidClientField": "クライアント {client}: フィールド {field} — {reason}",
         "invalidField": "{field} — {reason}",
+        "invalidCertificate": "TLS 証明書 {index}: {reason}",
         "moreIssues": "{message}  (他 {count} 件)"
       },
       "form": {
@@ -631,6 +632,9 @@
         "shortIds": "Short IDs",
         "realityTargetHint": "必須です。ポートを含める必要があります(例: example.com:443)。ポートがないと Xray-core は起動しません。",
         "realityTargetRequired": "REALITY ターゲットは必須です",
+        "tlsCertificateRequired": "保存する前に TLS 証明書をインポートするか、証明書ファイルのパスを入力してください",
+        "tlsPrivateKeyRequired": "保存する前に TLS 秘密鍵をインポートするか、秘密鍵ファイルのパスを入力してください",
+        "tlsServerCertificateRequired": "TLS には、秘密鍵を含むサーバー証明書が少なくとも 1 組必要です(encipherment または issue)",
         "realityTargetNeedsPort": "REALITY ターゲットにはポートを含める必要があります(例: example.com:443)",
         "realityTargetInvalidPort": "REALITY ターゲットのポートが無効です",
         "scan": "スキャン",

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

@@ -453,6 +453,7 @@
         "scanRealityTargetPrivate": "O destino funciona, mas está em uma rede privada/local.",
         "invalidClientField": "Cliente {client}: campo {field} — {reason}",
         "invalidField": "{field} — {reason}",
+        "invalidCertificate": "Certificado TLS {index}: {reason}",
         "moreIssues": "{message}  (+{count} mais)"
       },
       "form": {
@@ -631,6 +632,9 @@
         "shortIds": "Short IDs",
         "realityTargetHint": "Obrigatório. Deve incluir uma porta (ex.: example.com:443). Sem porta, o Xray-core não inicia.",
         "realityTargetRequired": "O alvo REALITY é obrigatório",
+        "tlsCertificateRequired": "Importe um certificado TLS ou informe o caminho do arquivo antes de salvar",
+        "tlsPrivateKeyRequired": "Importe a chave privada TLS ou informe o caminho do arquivo antes de salvar",
+        "tlsServerCertificateRequired": "O TLS exige pelo menos um certificado de servidor com sua chave privada (encipherment ou issue)",
         "realityTargetNeedsPort": "O alvo REALITY deve incluir uma porta (ex.: example.com:443)",
         "realityTargetInvalidPort": "O alvo REALITY tem uma porta inválida",
         "scan": "Escanear",

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

@@ -453,6 +453,7 @@
         "scanRealityTargetPrivate": "Цель работает, но находится в приватной (локальной) сети.",
         "invalidClientField": "Клиент {client}: поле {field} — {reason}",
         "invalidField": "{field} — {reason}",
+        "invalidCertificate": "Сертификат TLS {index}: {reason}",
         "moreIssues": "{message}  (+{count} ещё)"
       },
       "form": {
@@ -631,6 +632,9 @@
         "shortIds": "Short IDs",
         "realityTargetHint": "Обязательно. Должно содержать порт (например, example.com:443). Без порта Xray-core не запускается.",
         "realityTargetRequired": "Цель REALITY обязательна",
+        "tlsCertificateRequired": "Перед сохранением импортируйте сертификат TLS или укажите путь к его файлу",
+        "tlsPrivateKeyRequired": "Перед сохранением импортируйте закрытый ключ TLS или укажите путь к его файлу",
+        "tlsServerCertificateRequired": "Для TLS требуется хотя бы один сертификат сервера с закрытым ключом (encipherment или issue)",
         "realityTargetNeedsPort": "Цель REALITY должна содержать порт (например, example.com:443)",
         "realityTargetInvalidPort": "У цели REALITY указан недопустимый порт",
         "scan": "Сканировать",

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

@@ -453,6 +453,7 @@
         "scanRealityTargetPrivate": "Hedef çalışıyor ancak özel/yerel bir ağda.",
         "invalidClientField": "Kullanıcı {client}: {field} — {reason}",
         "invalidField": "{field} — {reason}",
+        "invalidCertificate": "TLS sertifikası {index}: {reason}",
         "moreIssues": "{message}  (+{count} tane daha)"
       },
       "form": {
@@ -610,6 +611,9 @@
         "shortIds": "Kısa Kimlikler",
         "realityTargetHint": "Zorunlu. Bir port içermelidir (ör. example.com:443). Port belirtilmezse Xray-core başlamaz.",
         "realityTargetRequired": "REALITY hedefi zorunludur",
+        "tlsCertificateRequired": "Kaydetmeden önce bir TLS sertifikası içe aktarın veya sertifika dosyasının yolunu girin",
+        "tlsPrivateKeyRequired": "Kaydetmeden önce TLS özel anahtarını içe aktarın veya özel anahtar dosyasının yolunu girin",
+        "tlsServerCertificateRequired": "TLS, özel anahtarıyla birlikte en az bir sunucu sertifikası gerektirir (encipherment veya issue)",
         "realityTargetNeedsPort": "REALITY hedefi bir port içermelidir (ör. example.com:443)",
         "realityTargetInvalidPort": "REALITY hedefinde geçersiz bir port var",
         "scan": "Tara",

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

@@ -453,6 +453,7 @@
         "scanRealityTargetPrivate": "Ціль працює, але розташована у приватній (локальній) мережі.",
         "invalidClientField": "Клієнт {client}: поле {field} — {reason}",
         "invalidField": "{field} — {reason}",
+        "invalidCertificate": "Сертифікат TLS {index}: {reason}",
         "moreIssues": "{message}  (+{count} ще)"
       },
       "form": {
@@ -610,6 +611,9 @@
         "shortIds": "Short IDs",
         "realityTargetHint": "Обов'язково. Має містити порт (напр., example.com:443). Без порту Xray-core не запускається.",
         "realityTargetRequired": "Ціль REALITY обов'язкова",
+        "tlsCertificateRequired": "Перед збереженням імпортуйте сертифікат TLS або вкажіть шлях до його файлу",
+        "tlsPrivateKeyRequired": "Перед збереженням імпортуйте приватний ключ TLS або вкажіть шлях до його файлу",
+        "tlsServerCertificateRequired": "Для TLS потрібен принаймні один сертифікат сервера з приватним ключем (encipherment або issue)",
         "realityTargetNeedsPort": "Ціль REALITY має містити порт (напр., example.com:443)",
         "realityTargetInvalidPort": "Ціль REALITY має недійсний порт",
         "scan": "Сканувати",

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

@@ -453,6 +453,7 @@
         "scanRealityTargetPrivate": "Đích hoạt động nhưng nằm trong mạng riêng/nội bộ.",
         "invalidClientField": "Khách hàng {client}: trường {field} — {reason}",
         "invalidField": "{field} — {reason}",
+        "invalidCertificate": "Chứng chỉ TLS {index}: {reason}",
         "moreIssues": "{message}  (+{count} lỗi khác)"
       },
       "form": {
@@ -631,6 +632,9 @@
         "shortIds": "Short IDs",
         "realityTargetHint": "Bắt buộc. Phải bao gồm cổng (ví dụ example.com:443). Không có cổng, Xray-core sẽ không khởi động.",
         "realityTargetRequired": "Mục tiêu REALITY là bắt buộc",
+        "tlsCertificateRequired": "Nhập chứng chỉ TLS hoặc điền đường dẫn tệp chứng chỉ trước khi lưu",
+        "tlsPrivateKeyRequired": "Nhập khóa riêng TLS hoặc điền đường dẫn tệp khóa riêng trước khi lưu",
+        "tlsServerCertificateRequired": "TLS yêu cầu ít nhất một chứng chỉ máy chủ kèm khóa riêng (encipherment hoặc issue)",
         "realityTargetNeedsPort": "Mục tiêu REALITY phải bao gồm cổng (ví dụ example.com:443)",
         "realityTargetInvalidPort": "Mục tiêu REALITY có cổng không hợp lệ",
         "scan": "Quét",

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

@@ -453,6 +453,7 @@
         "scanRealityTargetPrivate": "目标可用,但位于内网/本地网络中。",
         "invalidClientField": "客户端 {client}:字段 {field} — {reason}",
         "invalidField": "{field} — {reason}",
+        "invalidCertificate": "第 {index} 组 TLS 证书:{reason}",
         "moreIssues": "{message}  (另有 {count} 项)"
       },
       "form": {
@@ -630,6 +631,9 @@
         "shortIds": "Short IDs",
         "realityTargetHint": "必填。必须包含端口(例如 example.com:443)。没有端口时 Xray-core 将无法启动。",
         "realityTargetRequired": "REALITY 目标为必填项",
+        "tlsCertificateRequired": "请先导入 TLS 证书或填写证书文件路径,再保存入站",
+        "tlsPrivateKeyRequired": "请先导入 TLS 私钥或填写私钥文件路径,再保存入站",
+        "tlsServerCertificateRequired": "TLS 至少需要一组包含私钥的服务端证书(encipherment 或 issue)",
         "realityTargetNeedsPort": "REALITY 目标必须包含端口(例如 example.com:443)",
         "realityTargetInvalidPort": "REALITY 目标的端口无效",
         "scan": "扫描",

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

@@ -453,6 +453,7 @@
         "scanRealityTargetPrivate": "目標可用,但位於內網/本機網路中。",
         "invalidClientField": "用戶端 {client}:欄位 {field} — {reason}",
         "invalidField": "{field} — {reason}",
+        "invalidCertificate": "第 {index} 組 TLS 憑證:{reason}",
         "moreIssues": "{message}  (另有 {count} 項)"
       },
       "form": {
@@ -610,6 +611,9 @@
         "shortIds": "Short IDs",
         "realityTargetHint": "必填。必須包含連接埠(例如 example.com:443)。沒有連接埠時 Xray-core 將無法啟動。",
         "realityTargetRequired": "REALITY 目標為必填項",
+        "tlsCertificateRequired": "請先匯入 TLS 憑證或填寫憑證檔案路徑,再儲存入站",
+        "tlsPrivateKeyRequired": "請先匯入 TLS 私鑰或填寫私鑰檔案路徑,再儲存入站",
+        "tlsServerCertificateRequired": "TLS 至少需要一組包含私鑰的伺服器憑證(encipherment 或 issue)",
         "realityTargetNeedsPort": "REALITY 目標必須包含連接埠(例如 example.com:443)",
         "realityTargetInvalidPort": "REALITY 目標的連接埠無效",
         "scan": "掃描",