mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-16 16:20:59 +00:00
feat(tls,reality): port xray TLS/REALITY fields, cert-hash helpers, fallback UX
TLS: add verifyPeerCertByName (vcn) to inbound settings + emit in both share-link generators (frontend + Go sub) and outbound parser; the allowInsecure replacement xray removed after 2026-06-01. Add server-side curvePreferences, masterKeyLog, echSockopt (passthrough + form) at tlsSettings top-level so they survive the panel-only settings strip. REALITY: add limitFallbackUpload/Download (afterBytes/bytesPerSec/burstBytesPerSec) with per-field tooltips, plus masterKeyLog. Verified field names/semantics against pinned xray v1.260327.1 (bytesPerSec=0 disables). Hosts: fix verify_peer_cert_by_name column bool->string (xray expects comma-separated names) with an idempotent, history-gate-free migration (SQLite typeof blank; Postgres ALTER once); emit vcn for hosts/external proxies. Server: add getCertHash (local cert DER SHA-256) and getRemoteCertHash (xray tls ping) endpoints + api-docs; wire pinned-cert field buttons. Drop the meaningless random-hash button. Xray UI: metrics endpoint (listen/tag) config in Basics; import/export for routing rules and outbounds. Fallbacks card: compact empty state, header-aligned actions, responsive labeled grid rows. i18n: add all new keys to every locale; drop unused generateRandomPin.
This commit is contained in:
@@ -70,6 +70,28 @@ export default function BasicsTab({
|
||||
[mutate],
|
||||
);
|
||||
|
||||
const metricsCfg = (templateSettings as { metrics?: { tag?: string; listen?: string } } | null)?.metrics;
|
||||
|
||||
const setMetrics = useCallback(
|
||||
(field: 'tag' | 'listen', value: string) => mutate((tt) => {
|
||||
const node = tt as { metrics?: { tag?: string; listen?: string }; stats?: Record<string, unknown> };
|
||||
const m: { tag?: string; listen?: string } = { ...(node.metrics ?? {}) };
|
||||
if (value.trim() === '') {
|
||||
delete m[field];
|
||||
} else {
|
||||
m[field] = value.trim();
|
||||
}
|
||||
if (!m.listen && !m.tag) {
|
||||
delete node.metrics;
|
||||
} else {
|
||||
node.metrics = m;
|
||||
// xray-core's metrics handler needs a stats object to populate.
|
||||
if (!node.stats) node.stats = {};
|
||||
}
|
||||
}),
|
||||
[mutate],
|
||||
);
|
||||
|
||||
function confirmResetDefault() {
|
||||
modal.confirm({
|
||||
title: t('pages.settings.resetDefaultConfig'),
|
||||
@@ -272,6 +294,29 @@ export default function BasicsTab({
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<SettingListItem
|
||||
title={t('pages.xray.metricsListen')}
|
||||
description={t('pages.xray.metricsListenDesc')}
|
||||
paddings="small"
|
||||
control={
|
||||
<Input
|
||||
value={metricsCfg?.listen ?? ''}
|
||||
onChange={(e) => setMetrics('listen', e.target.value)}
|
||||
placeholder="127.0.0.1:11111"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingListItem
|
||||
title={t('pages.xray.metricsTag')}
|
||||
paddings="small"
|
||||
control={
|
||||
<Input
|
||||
value={metricsCfg?.tag ?? ''}
|
||||
onChange={(e) => setMetrics('tag', e.target.value)}
|
||||
placeholder="metrics_out"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -34,9 +34,11 @@ import {
|
||||
CheckCircleOutlined,
|
||||
WarningOutlined,
|
||||
ExportOutlined,
|
||||
ImportOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { FileManager, HttpUtil } from '@/utils';
|
||||
import PromptModal from '@/components/feedback/PromptModal';
|
||||
|
||||
import OutboundFormModal from './OutboundFormModal';
|
||||
import { propagateOutboundTagRename } from '../basics/helpers';
|
||||
@@ -223,6 +225,35 @@ export default function OutboundsTab({
|
||||
});
|
||||
}
|
||||
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
|
||||
function exportOutbounds() {
|
||||
FileManager.downloadTextFile(JSON.stringify(outbounds, null, 2), 'outbounds.json', {
|
||||
type: 'application/json',
|
||||
});
|
||||
}
|
||||
|
||||
function importOutbounds(value: string) {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
messageApi.error(t('pages.xray.importInvalidJson'));
|
||||
return;
|
||||
}
|
||||
const obj = parsed as { outbounds?: unknown };
|
||||
const list = Array.isArray(parsed) ? parsed : Array.isArray(obj?.outbounds) ? obj.outbounds : null;
|
||||
if (!list) {
|
||||
messageApi.error(t('pages.xray.importInvalidJson'));
|
||||
return;
|
||||
}
|
||||
mutate((tt) => {
|
||||
if (!Array.isArray(tt.outbounds)) tt.outbounds = [];
|
||||
tt.outbounds.push(...(list as never[]));
|
||||
});
|
||||
setImportOpen(false);
|
||||
}
|
||||
|
||||
// --- Subscription management (minimal inline UI) ---
|
||||
async function loadSubs() {
|
||||
setSubsLoading(true);
|
||||
@@ -420,6 +451,9 @@ export default function OutboundsTab({
|
||||
items: [
|
||||
{ key: 'warp', icon: <CloudOutlined />, label: 'WARP', onClick: onShowWarp },
|
||||
{ key: 'nord', icon: <ApiOutlined />, label: 'NordVPN', onClick: onShowNord },
|
||||
{ type: 'divider' },
|
||||
{ key: 'import', icon: <ImportOutlined />, label: t('pages.xray.importOutbounds'), onClick: () => setImportOpen(true) },
|
||||
{ key: 'export', icon: <ExportOutlined />, label: t('pages.xray.exportOutbounds'), disabled: outbounds.length === 0, onClick: exportOutbounds },
|
||||
],
|
||||
}}
|
||||
>
|
||||
@@ -488,6 +522,15 @@ export default function OutboundsTab({
|
||||
onClose={() => setModalOpen(false)}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
<PromptModal
|
||||
open={importOpen}
|
||||
onClose={() => setImportOpen(false)}
|
||||
title={t('pages.xray.importOutbounds')}
|
||||
okText={t('pages.xray.importOutbounds')}
|
||||
type="textarea"
|
||||
json
|
||||
onConfirm={importOutbounds}
|
||||
/>
|
||||
|
||||
{/* Subscription outbounds (read-only, merged at runtime) */}
|
||||
{Array.isArray(subscriptionOutbounds) && subscriptionOutbounds.length > 0 && (
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Modal, Space, Table, Tabs } from 'antd';
|
||||
import { AimOutlined, ControlOutlined, PlusOutlined, UnorderedListOutlined } from '@ant-design/icons';
|
||||
import { Button, Modal, Space, Table, Tabs, message } from 'antd';
|
||||
import {
|
||||
AimOutlined,
|
||||
ControlOutlined,
|
||||
ExportOutlined,
|
||||
ImportOutlined,
|
||||
PlusOutlined,
|
||||
UnorderedListOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { catTabLabel } from '@/pages/settings/catTabLabel';
|
||||
import { FileManager } from '@/utils';
|
||||
import PromptModal from '@/components/feedback/PromptModal';
|
||||
import RoutingBasic from './RoutingBasic';
|
||||
import RouteTester from './RouteTester';
|
||||
import RuleFormModal from './RuleFormModal';
|
||||
@@ -134,6 +143,42 @@ export default function RoutingTab({
|
||||
return out;
|
||||
}, [templateSettings?.routing?.balancers]);
|
||||
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
|
||||
function exportRules() {
|
||||
FileManager.downloadTextFile(JSON.stringify(rules, null, 2), 'routing-rules.json', {
|
||||
type: 'application/json',
|
||||
});
|
||||
}
|
||||
|
||||
function importRules(value: string) {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
message.error(t('pages.xray.importInvalidJson'));
|
||||
return;
|
||||
}
|
||||
const obj = parsed as { rules?: unknown; routing?: { rules?: unknown } };
|
||||
const list = Array.isArray(parsed)
|
||||
? parsed
|
||||
: Array.isArray(obj?.rules)
|
||||
? obj.rules
|
||||
: Array.isArray(obj?.routing?.rules)
|
||||
? obj.routing!.rules
|
||||
: null;
|
||||
if (!list) {
|
||||
message.error(t('pages.xray.importInvalidJson'));
|
||||
return;
|
||||
}
|
||||
mutate((tt) => {
|
||||
if (!tt.routing) tt.routing = { rules: [] };
|
||||
if (!Array.isArray(tt.routing.rules)) tt.routing.rules = [];
|
||||
tt.routing.rules.push(...(list as RuleObject[]));
|
||||
});
|
||||
setImportOpen(false);
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
setEditingRule(null);
|
||||
setEditingIndex(null);
|
||||
@@ -284,9 +329,21 @@ export default function RoutingTab({
|
||||
label: catTabLabel(<UnorderedListOutlined />, t('pages.xray.Routings'), isMobile),
|
||||
children: (
|
||||
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
|
||||
{t('pages.xray.Routings')}
|
||||
</Button>
|
||||
<Space wrap>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
|
||||
{t('pages.xray.Routings')}
|
||||
</Button>
|
||||
<Button icon={<ImportOutlined />} onClick={() => setImportOpen(true)}>
|
||||
{t('pages.xray.importRules')}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<ExportOutlined />}
|
||||
onClick={exportRules}
|
||||
disabled={rules.length === 0}
|
||||
>
|
||||
{t('pages.xray.exportRules')}
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
{isMobile ? (
|
||||
<RuleCardList
|
||||
@@ -339,6 +396,15 @@ export default function RoutingTab({
|
||||
onClose={() => setRuleModalOpen(false)}
|
||||
onConfirm={onRuleConfirm}
|
||||
/>
|
||||
<PromptModal
|
||||
open={importOpen}
|
||||
onClose={() => setImportOpen(false)}
|
||||
title={t('pages.xray.importRules')}
|
||||
okText={t('pages.xray.importRules')}
|
||||
type="textarea"
|
||||
json
|
||||
onConfirm={importRules}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user