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

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

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

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

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

The security union is parameterised on its tlsSettings branch instead of copied,
and tlsCertUsesFiles is the one file-vs-inline inference shared by the form
schema and the adapter, so the mode the editor opens in and the pair of fields
the save serializes cannot drift apart.
This commit is contained in:
DuQi
2026-09-08 17:47:12 +02:00
committed by Sanaei
parent 9f76a66dcf
commit 47d2303334
28 changed files with 697 additions and 23 deletions

View File

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

View File

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

View File

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

View File

@@ -2,11 +2,65 @@ import { z } from 'zod';
import { InboundPortSchema, SniffingSchema } from '@/schemas/primitives';
import { InboundSettingsSchema } from '@/schemas/protocols/inbound';
import { SecuritySettingsSchema } from '@/schemas/protocols/security';
import {
TlsCertInlineSchema,
TlsStreamSettingsSchema,
securitySettingsSchemaFor,
tlsCertUsesFiles,
} from '@/schemas/protocols/security';
import { NetworkSettingsSchema, StreamExtrasSchema } from '@/schemas/protocols/stream';
export const InboundStreamFormSchema =
NetworkSettingsSchema.and(SecuritySettingsSchema).and(StreamExtrasSchema);
// Inbound certificates must follow the selected editor mode. The shared wire
// union also serves outbound TLS, where a client certificate is optional.
const InboundTlsCertFieldsSchema = TlsCertInlineSchema.extend({
useFile: z.boolean().optional(),
certificateFile: z.string().default(''),
keyFile: z.string().default(''),
certificate: z.array(z.string()).default([]),
key: z.array(z.string()).default([]),
});
const InboundTlsCertSchema = InboundTlsCertFieldsSchema.superRefine((cert, ctx) => {
const useFile = tlsCertUsesFiles(cert);
const hasCertificate = useFile
? cert.certificateFile.trim() !== ''
: cert.certificate.some((line) => line.trim() !== '');
const hasKey = useFile ? cert.keyFile.trim() !== '' : cert.key.some((line) => line.trim() !== '');
if (!hasCertificate) {
ctx.addIssue({
code: 'custom',
path: [useFile ? 'certificateFile' : 'certificate'],
message: 'pages.inbounds.form.tlsCertificateRequired',
});
}
if (cert.usage !== 'verify' && !hasKey) {
ctx.addIssue({
code: 'custom',
path: [useFile ? 'keyFile' : 'key'],
message: 'pages.inbounds.form.tlsPrivateKeyRequired',
});
}
}).transform((cert) => {
const { useFile: _useFile, certificateFile, keyFile, certificate, key, ...settings } = cert;
return tlsCertUsesFiles(cert)
? { ...settings, certificateFile, keyFile }
: { ...settings, certificate, key };
});
const InboundTlsSettingsSchema = TlsStreamSettingsSchema.extend({
certificates: z
.array(InboundTlsCertSchema)
.default([])
.refine((certificates) => certificates.some((cert) => cert.usage !== 'verify'), {
message: 'pages.inbounds.form.tlsServerCertificateRequired',
}),
});
const InboundSecuritySettingsSchema = securitySettingsSchemaFor(InboundTlsSettingsSchema);
export const InboundStreamFormSchema = NetworkSettingsSchema.and(InboundSecuritySettingsSchema).and(
StreamExtrasSchema,
);
export type InboundStreamFormValues = z.infer<typeof InboundStreamFormSchema>;
export const TrafficResetSchema = z.enum(['never', 'hourly', 'daily', 'weekly', 'monthly']);

View File

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

View File

@@ -52,9 +52,32 @@ export const TlsCertInlineSchema = z.object({
usage: TlsCertUsageSchema.default('encipherment'),
buildChain: z.boolean().default(false),
});
export const TlsCertSchema = z.union([TlsCertFileSchema, TlsCertInlineSchema]);
export const TlsCertSchema = z.union([
TlsCertFileSchema,
TlsCertInlineSchema,
// Verification CAs contain only public certificates. Their omitted private
// keys must survive reading a saved inbound for details and share links.
TlsCertFileSchema.extend({ usage: z.literal('verify'), keyFile: z.string().optional() }),
TlsCertInlineSchema.extend({ usage: z.literal('verify'), key: z.array(z.string()).optional() }),
]);
export type TlsCert = z.infer<typeof TlsCertSchema>;
// A stored certificate predates the panel's `useFile` toggle when the boolean is
// absent; infer the editor mode from whichever half of the credential is filled.
export function tlsCertUsesFiles(cert: {
useFile?: unknown;
certificateFile?: unknown;
keyFile?: unknown;
certificate?: unknown;
key?: unknown;
}): boolean {
if (typeof cert.useFile === 'boolean') return cert.useFile;
const hasInline =
(Array.isArray(cert.certificate) && cert.certificate.length > 0) ||
(Array.isArray(cert.key) && cert.key.length > 0);
return !!cert.certificateFile || !!cert.keyFile || !hasInline;
}
export const TlsClientSettingsSchema = z.object({
// '' = None. Hysteria rejects uTLS fingerprints, and a chrome default
// silently flipped the form's None back to chrome on every save.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"}`,
}
}

View File

@@ -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")
}
})
}
}

View File

@@ -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": "فحص",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "اسکن",

View File

@@ -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",

View File

@@ -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": "スキャン",

View File

@@ -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",

View File

@@ -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": "Сканировать",

View File

@@ -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",

View File

@@ -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": "Сканувати",

View File

@@ -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",

View File

@@ -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": "扫描",

View File

@@ -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": "掃描",