From 47d2303334d5962621b0d2d849d44569aebfbf39 Mon Sep 17 00:00:00 2001 From: DuQi <916475145@qq.com> Date: Tue, 8 Sep 2026 17:47:12 +0200 Subject: [PATCH] fix(inbounds): reject missing TLS certificates before saving (#6429) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/lib/xray/inbound-form-adapter.ts | 10 +- .../pages/inbounds/form/InboundFormModal.tsx | 1 + .../inbounds/form/formatValidationError.ts | 6 + frontend/src/schemas/forms/inbound-form.ts | 60 +++++- .../src/schemas/protocols/security/index.ts | 21 +- .../src/schemas/protocols/security/tls.ts | 25 ++- .../src/test/format-validation-error.test.ts | 9 + frontend/src/test/inbound-form-modal.test.tsx | 22 +++ .../src/test/inbound-tls-validation.test.ts | 145 ++++++++++++++ .../src/test/stream-wire-normalize.test.ts | 1 + internal/web/controller/inbound.go | 13 +- .../web/controller/inbound_node_sync_test.go | 93 +++++++++ internal/web/service/inbound.go | 75 +++++++ .../service/inbound_durable_postgres_test.go | 2 +- internal/web/service/inbound_tls_test.go | 185 ++++++++++++++++++ internal/web/translation/ar-EG.json | 4 + internal/web/translation/en-US.json | 4 + internal/web/translation/es-ES.json | 4 + internal/web/translation/fa-IR.json | 4 + internal/web/translation/id-ID.json | 4 + internal/web/translation/ja-JP.json | 4 + internal/web/translation/pt-BR.json | 4 + internal/web/translation/ru-RU.json | 4 + internal/web/translation/tr-TR.json | 4 + internal/web/translation/uk-UA.json | 4 + internal/web/translation/vi-VN.json | 4 + internal/web/translation/zh-CN.json | 4 + internal/web/translation/zh-TW.json | 4 + 28 files changed, 697 insertions(+), 23 deletions(-) create mode 100644 frontend/src/test/inbound-tls-validation.test.ts create mode 100644 internal/web/controller/inbound_node_sync_test.go create mode 100644 internal/web/service/inbound_tls_test.go diff --git a/frontend/src/lib/xray/inbound-form-adapter.ts b/frontend/src/lib/xray/inbound-form-adapter.ts index c6b5ad52f..781276b77 100644 --- a/frontend/src/lib/xray/inbound-form-adapter.ts +++ b/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): Record[] { } function synthesizeTlsCertUseFile(stream: Record): 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): void { diff --git a/frontend/src/pages/inbounds/form/InboundFormModal.tsx b/frontend/src/pages/inbounds/form/InboundFormModal.tsx index 6853b4b4e..33777ad5b 100644 --- a/frontend/src/pages/inbounds/form/InboundFormModal.tsx +++ b/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:', diff --git a/frontend/src/pages/inbounds/form/formatValidationError.ts b/frontend/src/pages/inbounds/form/formatValidationError.ts index 598b300cf..a55183c35 100644 --- a/frontend/src/pages/inbounds/form/formatValidationError.ts +++ b/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; diff --git a/frontend/src/schemas/forms/inbound-form.ts b/frontend/src/schemas/forms/inbound-form.ts index e9916cef5..4a886dc91 100644 --- a/frontend/src/schemas/forms/inbound-form.ts +++ b/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; export const TrafficResetSchema = z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']); diff --git a/frontend/src/schemas/protocols/security/index.ts b/frontend/src/schemas/protocols/security/index.ts index 9907dd970..09a00136a 100644 --- a/frontend/src/schemas/protocols/security/index.ts +++ b/frontend/src/schemas/protocols/security/index.ts @@ -21,12 +21,17 @@ export type Security = z.infer; // 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(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; diff --git a/frontend/src/schemas/protocols/security/tls.ts b/frontend/src/schemas/protocols/security/tls.ts index 8277c9582..ed105f577 100644 --- a/frontend/src/schemas/protocols/security/tls.ts +++ b/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; +// 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. diff --git a/frontend/src/test/format-validation-error.test.ts b/frontend/src/test/format-validation-error.test.ts index 9927d8e4a..4acc5d4cc 100644 --- a/frontend/src/test/format-validation-error.test.ts +++ b/frontend/src/test/format-validation-error.test.ts @@ -12,6 +12,7 @@ const templates: Record = { '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' }, diff --git a/frontend/src/test/inbound-form-modal.test.tsx b/frontend/src/test/inbound-form-modal.test.tsx index 3618ee470..c6a86ac17 100644 --- a/frontend/src/test/inbound-form-modal.test.tsx +++ b/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(); diff --git a/frontend/src/test/inbound-tls-validation.test.ts b/frontend/src/test/inbound-tls-validation.test.ts new file mode 100644 index 000000000..5d78c633b --- /dev/null +++ b/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); + }); +}); diff --git a/frontend/src/test/stream-wire-normalize.test.ts b/frontend/src/test/stream-wire-normalize.test.ts index ebe481be1..e555ae0a4 100644 --- a/frontend/src/test/stream-wire-normalize.test.ts +++ b/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: '', }, diff --git a/internal/web/controller/inbound.go b/internal/web/controller/inbound.go index f9460e8e7..956a18aaa 100644 --- a/internal/web/controller/inbound.go +++ b/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 diff --git a/internal/web/controller/inbound_node_sync_test.go b/internal/web/controller/inbound_node_sync_test.go new file mode 100644 index 000000000..f09be717f --- /dev/null +++ b/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) + } + }) +} diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index 8e199cfc2..94a76ec58 100644 --- a/internal/web/service/inbound.go +++ b/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 diff --git a/internal/web/service/inbound_durable_postgres_test.go b/internal/web/service/inbound_durable_postgres_test.go index 5ec4a47bd..b4a7c1b17 100644 --- a/internal/web/service/inbound_durable_postgres_test.go +++ b/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"}`, } } diff --git a/internal/web/service/inbound_tls_test.go b/internal/web/service/inbound_tls_test.go new file mode 100644 index 000000000..105ca2400 --- /dev/null +++ b/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") + } + }) + } +} diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index 85b196fee..541b3515a 100644 --- a/internal/web/translation/ar-EG.json +++ b/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": "فحص", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index c44a1ec3c..d8db76dff 100644 --- a/internal/web/translation/en-US.json +++ b/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", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 61f0418f8..a2f5c68bc 100644 --- a/internal/web/translation/es-ES.json +++ b/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", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index d527d72c9..6486237e3 100644 --- a/internal/web/translation/fa-IR.json +++ b/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": "اسکن", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 2adc9179c..3392af6cb 100644 --- a/internal/web/translation/id-ID.json +++ b/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", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index 85e7515e8..bd37238f6 100644 --- a/internal/web/translation/ja-JP.json +++ b/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": "スキャン", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index 79ddaabd3..54c820167 100644 --- a/internal/web/translation/pt-BR.json +++ b/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", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index 06c452ab1..0463b3180 100644 --- a/internal/web/translation/ru-RU.json +++ b/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": "Сканировать", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 638563309..5e5f6a89f 100644 --- a/internal/web/translation/tr-TR.json +++ b/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", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index 17c1f4b8c..e0172f345 100644 --- a/internal/web/translation/uk-UA.json +++ b/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": "Сканувати", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 487a265f9..2510d3cdd 100644 --- a/internal/web/translation/vi-VN.json +++ b/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", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 6d84d78a2..fa9fb9297 100644 --- a/internal/web/translation/zh-CN.json +++ b/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": "扫描", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index f1eb582ca..01efdd1a5 100644 --- a/internal/web/translation/zh-TW.json +++ b/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": "掃描",