mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-15 09:35:00 +03:00
Compare commits
7 Commits
v3.2.8
...
6ed6f57b5c
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ed6f57b5c | |||
| e409bc305d | |||
| 2b4e199a97 | |||
| 75bc6e8076 | |||
| eeb19b7240 | |||
| 5b9db13e55 | |||
| 0706b0b3a8 |
@@ -29,7 +29,7 @@ function defaultTcpMaskSettings(type: string): Record<string, unknown> {
|
|||||||
return { packets: '1-3', length: '', delay: '', maxSplit: '' };
|
return { packets: '1-3', length: '', delay: '', maxSplit: '' };
|
||||||
case 'sudoku':
|
case 'sudoku':
|
||||||
return {
|
return {
|
||||||
password: '', ascii: '', customTable: '', customTables: '',
|
password: '', ascii: '', customTable: '', customTables: [''],
|
||||||
paddingMin: 0, paddingMax: 0,
|
paddingMin: 0, paddingMax: 0,
|
||||||
};
|
};
|
||||||
case 'header-custom':
|
case 'header-custom':
|
||||||
@@ -228,7 +228,9 @@ function TcpMaskItem({
|
|||||||
<Form.Item label="Password" name={[fieldName, 'settings', 'password']}><Input /></Form.Item>
|
<Form.Item label="Password" name={[fieldName, 'settings', 'password']}><Input /></Form.Item>
|
||||||
<Form.Item label="ASCII" name={[fieldName, 'settings', 'ascii']}><Input /></Form.Item>
|
<Form.Item label="ASCII" name={[fieldName, 'settings', 'ascii']}><Input /></Form.Item>
|
||||||
<Form.Item label="Custom Table" name={[fieldName, 'settings', 'customTable']}><Input /></Form.Item>
|
<Form.Item label="Custom Table" name={[fieldName, 'settings', 'customTable']}><Input /></Form.Item>
|
||||||
<Form.Item label="Custom Tables" name={[fieldName, 'settings', 'customTables']}><Input /></Form.Item>
|
<Form.Item label="Custom Tables" name={[fieldName, 'settings', 'customTables']}>
|
||||||
|
<Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} />
|
||||||
|
</Form.Item>
|
||||||
<Form.Item label="Padding Min" name={[fieldName, 'settings', 'paddingMin']}>
|
<Form.Item label="Padding Min" name={[fieldName, 'settings', 'paddingMin']}>
|
||||||
<InputNumber min={0} />
|
<InputNumber min={0} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import type { StreamSettings } from '@/schemas/api/inbound';
|
import type { StreamSettings } from '@/schemas/api/inbound';
|
||||||
import type { Sniffing } from '@/schemas/primitives';
|
import type { Sniffing } from '@/schemas/primitives';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
|
||||||
|
|
||||||
// Plain-data adapter between the panel's stored inbound row shape and
|
// Plain-data adapter between the panel's stored inbound row shape and
|
||||||
// the typed InboundFormValues that Form.useForm<T> carries inside
|
// the typed InboundFormValues that Form.useForm<T> carries inside
|
||||||
@@ -279,10 +280,13 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP
|
|||||||
if (Array.isArray(settingsPruned.clients)) {
|
if (Array.isArray(settingsPruned.clients)) {
|
||||||
settingsPruned.clients = normalizeClients(values.protocol, settingsPruned.clients);
|
settingsPruned.clients = normalizeClients(values.protocol, settingsPruned.clients);
|
||||||
}
|
}
|
||||||
const streamPruned = values.streamSettings
|
let streamPruned = values.streamSettings
|
||||||
? ((pruneEmpty(values.streamSettings) ?? {}) as Record<string, unknown>)
|
? ((pruneEmpty(values.streamSettings) ?? {}) as Record<string, unknown>)
|
||||||
: undefined;
|
: undefined;
|
||||||
if (streamPruned) stripTlsCertUseFile(streamPruned);
|
if (streamPruned) {
|
||||||
|
streamPruned = normalizeStreamSettingsForWire(streamPruned, { side: 'inbound' });
|
||||||
|
stripTlsCertUseFile(streamPruned);
|
||||||
|
}
|
||||||
dropLegacyOptionalEmpties(settingsPruned, streamPruned);
|
dropLegacyOptionalEmpties(settingsPruned, streamPruned);
|
||||||
const payload: WireInboundPayload = {
|
const payload: WireInboundPayload = {
|
||||||
up: values.up,
|
up: values.up,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
|
import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
|
||||||
|
import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
|
||||||
import { Wireguard } from '@/utils';
|
import { Wireguard } from '@/utils';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -519,8 +520,8 @@ function freedomToWire(s: FreedomOutboundFormSettings) {
|
|||||||
userLevel: s.userLevel || undefined,
|
userLevel: s.userLevel || undefined,
|
||||||
proxyProtocol: s.proxyProtocol || undefined,
|
proxyProtocol: s.proxyProtocol || undefined,
|
||||||
fragment: fragmentEnabled ? Object.fromEntries(fragmentEntries) : undefined,
|
fragment: fragmentEnabled ? Object.fromEntries(fragmentEntries) : undefined,
|
||||||
noises: s.noises.length > 0 ? s.noises : undefined,
|
noises: s.noises && s.noises.length > 0 ? s.noises : undefined,
|
||||||
finalRules: s.finalRules.length > 0
|
finalRules: s.finalRules && s.finalRules.length > 0
|
||||||
? s.finalRules.map((r) => ({
|
? s.finalRules.map((r) => ({
|
||||||
action: r.action,
|
action: r.action,
|
||||||
network: r.network || undefined,
|
network: r.network || undefined,
|
||||||
@@ -588,7 +589,7 @@ function stripUiOnlyStreamFields(stream: unknown): Raw {
|
|||||||
if (!xmuxEnabled) delete cleaned.xmux;
|
if (!xmuxEnabled) delete cleaned.xmux;
|
||||||
next.xhttpSettings = dropEmptyStrings(cleaned);
|
next.xhttpSettings = dropEmptyStrings(cleaned);
|
||||||
}
|
}
|
||||||
return next;
|
return normalizeStreamSettingsForWire(next, { side: 'outbound' }) as Raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
function muxAllowed(values: OutboundFormValues): boolean {
|
function muxAllowed(values: OutboundFormValues): boolean {
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
// Shapes the streamSettings subtree that 3x-ui persists to match what
|
||||||
|
// xray-core actually consumes. The panel's Zod defaults mirror the full
|
||||||
|
// SplitHTTPConfig / SockoptObject schema, but many fields are mode-specific
|
||||||
|
// (packet-up vs stream-one) or side-specific (inbound vs outbound). Emitting
|
||||||
|
// them anyway bloats configs and — for sockopt — can inject doc-example
|
||||||
|
// values like tcpWindowClamp: 600 that throttle throughput.
|
||||||
|
|
||||||
|
export type StreamWireSide = 'inbound' | 'outbound';
|
||||||
|
|
||||||
|
const PACKET_UP_FIELDS = [
|
||||||
|
'scMaxEachPostBytes',
|
||||||
|
'scMinPostsIntervalMs',
|
||||||
|
'scMaxBufferedPosts',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const STREAM_UP_SERVER_FIELDS = ['scStreamUpServerSecs'] as const;
|
||||||
|
|
||||||
|
const PLACEMENT_STRING_FIELDS = [
|
||||||
|
'sessionPlacement',
|
||||||
|
'sessionKey',
|
||||||
|
'seqPlacement',
|
||||||
|
'seqKey',
|
||||||
|
'uplinkDataPlacement',
|
||||||
|
'uplinkDataKey',
|
||||||
|
'uplinkHTTPMethod',
|
||||||
|
'xPaddingKey',
|
||||||
|
'xPaddingHeader',
|
||||||
|
'xPaddingPlacement',
|
||||||
|
'xPaddingMethod',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function isRecord(v: unknown): v is Record<string, unknown> {
|
||||||
|
return v != null && typeof v === 'object' && !Array.isArray(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonEmptyString(v: unknown): v is string {
|
||||||
|
return typeof v === 'string' && v.trim() !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasMeaningfulHeaders(headers: unknown): boolean {
|
||||||
|
return isRecord(headers) && Object.keys(headers).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validates REALITY inbound `target` / `dest` (must include a port). */
|
||||||
|
export function validateRealityTarget(target: string): string | undefined {
|
||||||
|
const trimmed = target.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return 'pages.inbounds.form.realityTargetRequired';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unix socket destinations (rare, but valid in xray-core).
|
||||||
|
if (trimmed.startsWith('/') || trimmed.startsWith('@')) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pure port → localhost:port in xray-core.
|
||||||
|
if (/^\d+$/.test(trimmed)) {
|
||||||
|
const port = Number(trimmed);
|
||||||
|
if (port >= 1 && port <= 65535) return undefined;
|
||||||
|
return 'pages.inbounds.form.realityTargetInvalidPort';
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastColon = trimmed.lastIndexOf(':');
|
||||||
|
if (lastColon <= 0 || lastColon === trimmed.length - 1) {
|
||||||
|
return 'pages.inbounds.form.realityTargetNeedsPort';
|
||||||
|
}
|
||||||
|
|
||||||
|
const portPart = trimmed.slice(lastColon + 1);
|
||||||
|
if (!/^\d+$/.test(portPart)) {
|
||||||
|
return 'pages.inbounds.form.realityTargetInvalidPort';
|
||||||
|
}
|
||||||
|
const port = Number(portPart);
|
||||||
|
if (port < 1 || port > 65535) {
|
||||||
|
return 'pages.inbounds.form.realityTargetInvalidPort';
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropEmptyStrings(obj: Record<string, unknown>, keys: readonly string[]): void {
|
||||||
|
for (const key of keys) {
|
||||||
|
const v = obj[key];
|
||||||
|
if (v === '' || v == null) delete obj[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropFalseFlags(obj: Record<string, unknown>, keys: readonly string[]): void {
|
||||||
|
for (const key of keys) {
|
||||||
|
if (obj[key] === false) delete obj[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropZeroNumbers(obj: Record<string, unknown>, keys: readonly string[]): void {
|
||||||
|
for (const key of keys) {
|
||||||
|
if (obj[key] === 0) delete obj[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeXhttpForWire(
|
||||||
|
raw: Record<string, unknown>,
|
||||||
|
side: StreamWireSide,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = { ...raw };
|
||||||
|
const mode = typeof out.mode === 'string' && out.mode !== '' ? out.mode : 'auto';
|
||||||
|
|
||||||
|
delete out.enableXmux;
|
||||||
|
|
||||||
|
if (side === 'inbound') {
|
||||||
|
delete out.xmux;
|
||||||
|
delete out.scMinPostsIntervalMs;
|
||||||
|
delete out.uplinkChunkSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
dropEmptyStrings(out, PLACEMENT_STRING_FIELDS);
|
||||||
|
|
||||||
|
if (!hasMeaningfulHeaders(out.headers)) {
|
||||||
|
delete out.headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (out.xPaddingObfsMode !== true) {
|
||||||
|
delete out.xPaddingObfsMode;
|
||||||
|
dropEmptyStrings(out, [
|
||||||
|
'xPaddingKey',
|
||||||
|
'xPaddingHeader',
|
||||||
|
'xPaddingPlacement',
|
||||||
|
'xPaddingMethod',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (out.noGRPCHeader !== true) delete out.noGRPCHeader;
|
||||||
|
if (out.noSSEHeader !== true) delete out.noSSEHeader;
|
||||||
|
if (out.serverMaxHeaderBytes === 0) delete out.serverMaxHeaderBytes;
|
||||||
|
if (out.uplinkChunkSize === 0) delete out.uplinkChunkSize;
|
||||||
|
|
||||||
|
if (mode === 'stream-one') {
|
||||||
|
for (const key of PACKET_UP_FIELDS) delete out[key];
|
||||||
|
for (const key of STREAM_UP_SERVER_FIELDS) delete out[key];
|
||||||
|
} else if (mode === 'stream-up') {
|
||||||
|
for (const key of PACKET_UP_FIELDS) delete out[key];
|
||||||
|
if (side === 'outbound') {
|
||||||
|
delete out.scStreamUpServerSecs;
|
||||||
|
}
|
||||||
|
} else if (mode === 'packet-up') {
|
||||||
|
delete out.scStreamUpServerSecs;
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSockoptForWire(
|
||||||
|
raw: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> | undefined {
|
||||||
|
const out: Record<string, unknown> = { ...raw };
|
||||||
|
|
||||||
|
dropZeroNumbers(out, [
|
||||||
|
'tcpWindowClamp',
|
||||||
|
'tcpMaxSeg',
|
||||||
|
'tcpUserTimeout',
|
||||||
|
'tcpKeepAliveIdle',
|
||||||
|
'tcpKeepAliveInterval',
|
||||||
|
'mark',
|
||||||
|
]);
|
||||||
|
|
||||||
|
dropFalseFlags(out, [
|
||||||
|
'acceptProxyProtocol',
|
||||||
|
'tcpFastOpen',
|
||||||
|
'tcpMptcp',
|
||||||
|
'penetrate',
|
||||||
|
'V6Only',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (out.tproxy === 'off') delete out.tproxy;
|
||||||
|
if (out.domainStrategy === 'AsIs') delete out.domainStrategy;
|
||||||
|
if (out.addressPortStrategy === 'none') delete out.addressPortStrategy;
|
||||||
|
if (nonEmptyString(out.dialerProxy) === false) delete out.dialerProxy;
|
||||||
|
if (nonEmptyString(out.interface) === false) delete out.interface;
|
||||||
|
if (Array.isArray(out.trustedXForwardedFor) && out.trustedXForwardedFor.length === 0) {
|
||||||
|
delete out.trustedXForwardedFor;
|
||||||
|
}
|
||||||
|
if (Array.isArray(out.customSockopt) && out.customSockopt.length === 0) {
|
||||||
|
delete out.customSockopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const he = out.happyEyeballs;
|
||||||
|
if (isRecord(he)) {
|
||||||
|
const heOut: Record<string, unknown> = { ...he };
|
||||||
|
if (heOut.tryDelayMs === 0) delete heOut.tryDelayMs;
|
||||||
|
if (heOut.prioritizeIPv6 === false) delete heOut.prioritizeIPv6;
|
||||||
|
if (heOut.interleave === 1) delete heOut.interleave;
|
||||||
|
if (heOut.maxConcurrentTry === 4) delete heOut.maxConcurrentTry;
|
||||||
|
if (Object.keys(heOut).length === 0) {
|
||||||
|
delete out.happyEyeballs;
|
||||||
|
} else {
|
||||||
|
out.happyEyeballs = heOut;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nonEmptyString(out.tcpcongestion) === false) delete out.tcpcongestion;
|
||||||
|
|
||||||
|
if (Object.keys(out).length === 0) return undefined;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeStreamSettingsForWire(
|
||||||
|
stream: Record<string, unknown>,
|
||||||
|
opts: { side: StreamWireSide },
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = { ...stream };
|
||||||
|
|
||||||
|
const xhttp = out.xhttpSettings;
|
||||||
|
if (isRecord(xhttp)) {
|
||||||
|
out.xhttpSettings = normalizeXhttpForWire(xhttp, opts.side);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sockopt = out.sockopt;
|
||||||
|
if (isRecord(sockopt)) {
|
||||||
|
const normalized = normalizeSockoptForWire(sockopt);
|
||||||
|
if (normalized) {
|
||||||
|
out.sockopt = normalized;
|
||||||
|
} else {
|
||||||
|
delete out.sockopt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -875,6 +875,7 @@ export default function InboundFormModal({
|
|||||||
colon={false}
|
colon={false}
|
||||||
labelCol={{ sm: { span: 8 } }}
|
labelCol={{ sm: { span: 8 } }}
|
||||||
wrapperCol={{ sm: { span: 14 } }}
|
wrapperCol={{ sm: { span: 14 } }}
|
||||||
|
labelWrap
|
||||||
onValuesChange={onValuesChange}
|
onValuesChange={onValuesChange}
|
||||||
>
|
>
|
||||||
<Tabs items={[
|
<Tabs items={[
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
|
|||||||
import { ReloadOutlined } from '@ant-design/icons';
|
import { ReloadOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
import { UTLS_FINGERPRINT } from '@/schemas/primitives';
|
import { UTLS_FINGERPRINT } from '@/schemas/primitives';
|
||||||
|
import { validateRealityTarget } from '@/lib/xray/stream-wire-normalize';
|
||||||
|
|
||||||
interface RealityFormProps {
|
interface RealityFormProps {
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
@@ -44,10 +45,24 @@ export default function RealityForm({
|
|||||||
options={Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp }))}
|
options={Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp }))}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label={t('pages.inbounds.form.target')}>
|
<Form.Item
|
||||||
|
label={t('pages.inbounds.form.target')}
|
||||||
|
extra={t('pages.inbounds.form.realityTargetHint')}
|
||||||
|
>
|
||||||
<Space.Compact block>
|
<Space.Compact block>
|
||||||
<Form.Item name={['streamSettings', 'realitySettings', 'target']} noStyle>
|
<Form.Item
|
||||||
<Input style={{ width: 'calc(100% - 32px)' }} />
|
name={['streamSettings', 'realitySettings', 'target']}
|
||||||
|
noStyle
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
validator: async (_, value) => {
|
||||||
|
const errKey = validateRealityTarget(typeof value === 'string' ? value : '');
|
||||||
|
if (errKey) throw new Error(t(errKey));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input style={{ width: 'calc(100% - 32px)' }} placeholder="example.com:443" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Button icon={<ReloadOutlined />} onClick={randomizeRealityTarget} />
|
<Button icon={<ReloadOutlined />} onClick={randomizeRealityTarget} />
|
||||||
</Space.Compact>
|
</Space.Compact>
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export default function SockoptForm({
|
|||||||
<Form.Item
|
<Form.Item
|
||||||
name={['streamSettings', 'sockopt', 'tcpWindowClamp']}
|
name={['streamSettings', 'sockopt', 'tcpWindowClamp']}
|
||||||
label={t('pages.inbounds.form.tcpWindowClamp')}
|
label={t('pages.inbounds.form.tcpWindowClamp')}
|
||||||
|
tooltip={t('pages.inbounds.form.tcpWindowClampHint')}
|
||||||
>
|
>
|
||||||
<InputNumber min={0} />
|
<InputNumber min={0} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
import { OutboundDomainStrategies } from '@/schemas/primitives';
|
import { OutboundDomainStrategies } from '@/schemas/primitives';
|
||||||
|
import { HappyEyeballsSchema } from '@/schemas/protocols/stream/sockopt';
|
||||||
import { SettingListItem } from '@/components/ui';
|
import { SettingListItem } from '@/components/ui';
|
||||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
import { catTabLabel } from '@/pages/settings/catTabLabel';
|
import { catTabLabel } from '@/pages/settings/catTabLabel';
|
||||||
@@ -84,6 +85,49 @@ export default function BasicsTab({
|
|||||||
| { domainStrategy?: string }
|
| { domainStrategy?: string }
|
||||||
| undefined)?.domainStrategy ?? 'AsIs';
|
| undefined)?.domainStrategy ?? 'AsIs';
|
||||||
|
|
||||||
|
const directFreedomOutbound = templateSettings?.outbounds?.find(
|
||||||
|
(o) => o?.protocol === 'freedom' && o?.tag === 'direct',
|
||||||
|
);
|
||||||
|
const directHappyEyeballs = (() => {
|
||||||
|
const sockopt = (directFreedomOutbound?.streamSettings as { sockopt?: { happyEyeballs?: unknown } } | undefined)
|
||||||
|
?.sockopt;
|
||||||
|
const raw = sockopt?.happyEyeballs;
|
||||||
|
if (raw == null || typeof raw !== 'object') return null;
|
||||||
|
return HappyEyeballsSchema.parse(raw);
|
||||||
|
})();
|
||||||
|
|
||||||
|
const setDirectHappyEyeballs = useCallback(
|
||||||
|
(next: ReturnType<typeof HappyEyeballsSchema.parse> | null) => {
|
||||||
|
mutate((tt) => {
|
||||||
|
if (!tt.outbounds) tt.outbounds = [];
|
||||||
|
let idx = tt.outbounds.findIndex((o) => o?.protocol === 'freedom' && o?.tag === 'direct');
|
||||||
|
if (idx < 0) {
|
||||||
|
tt.outbounds.push({ protocol: 'freedom', tag: 'direct', settings: {} });
|
||||||
|
idx = tt.outbounds.length - 1;
|
||||||
|
}
|
||||||
|
const ob = tt.outbounds[idx];
|
||||||
|
const stream = (ob.streamSettings ?? {}) as Record<string, unknown>;
|
||||||
|
const sockopt = (stream.sockopt ?? {}) as Record<string, unknown>;
|
||||||
|
if (next == null) {
|
||||||
|
delete sockopt.happyEyeballs;
|
||||||
|
} else {
|
||||||
|
sockopt.happyEyeballs = next;
|
||||||
|
}
|
||||||
|
if (Object.keys(sockopt).length === 0) {
|
||||||
|
delete stream.sockopt;
|
||||||
|
} else {
|
||||||
|
stream.sockopt = sockopt;
|
||||||
|
}
|
||||||
|
if (Object.keys(stream).length === 0) {
|
||||||
|
delete ob.streamSettings;
|
||||||
|
} else {
|
||||||
|
ob.streamSettings = stream;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[mutate],
|
||||||
|
);
|
||||||
|
|
||||||
const routingStrategy = templateSettings?.routing?.domainStrategy ?? 'AsIs';
|
const routingStrategy = templateSettings?.routing?.domainStrategy ?? 'AsIs';
|
||||||
const log = (templateSettings?.log || {}) as Record<string, unknown>;
|
const log = (templateSettings?.log || {}) as Record<string, unknown>;
|
||||||
const policy = (templateSettings?.policy?.system || {}) as Record<string, boolean>;
|
const policy = (templateSettings?.policy?.system || {}) as Record<string, boolean>;
|
||||||
@@ -124,6 +168,53 @@ export default function BasicsTab({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<SettingListItem
|
||||||
|
title={t('pages.xray.FreedomHappyEyeballs')}
|
||||||
|
description={t('pages.xray.FreedomHappyEyeballsDesc')}
|
||||||
|
paddings="small"
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={directHappyEyeballs != null}
|
||||||
|
onChange={(checked) => {
|
||||||
|
setDirectHappyEyeballs(checked ? HappyEyeballsSchema.parse({}) : null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{directHappyEyeballs != null && (
|
||||||
|
<>
|
||||||
|
<SettingListItem
|
||||||
|
title={t('pages.inbounds.form.tryDelayMs')}
|
||||||
|
description={t('pages.xray.FreedomHappyEyeballsTryDelayDesc')}
|
||||||
|
paddings="small"
|
||||||
|
control={
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={directHappyEyeballs.tryDelayMs}
|
||||||
|
placeholder="150"
|
||||||
|
onChange={(v) => setDirectHappyEyeballs({
|
||||||
|
...directHappyEyeballs,
|
||||||
|
tryDelayMs: typeof v === 'number' ? v : 0,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SettingListItem
|
||||||
|
title={t('pages.inbounds.form.prioritizeIPv6')}
|
||||||
|
paddings="small"
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={directHappyEyeballs.prioritizeIPv6}
|
||||||
|
onChange={(checked) => setDirectHappyEyeballs({
|
||||||
|
...directHappyEyeballs,
|
||||||
|
prioritizeIPv6: checked,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<SettingListItem
|
<SettingListItem
|
||||||
title={t('pages.xray.RoutingStrategy')}
|
title={t('pages.xray.RoutingStrategy')}
|
||||||
description={t('pages.xray.RoutingStrategyDesc')}
|
description={t('pages.xray.RoutingStrategyDesc')}
|
||||||
|
|||||||
@@ -350,6 +350,7 @@ export default function OutboundFormModal({
|
|||||||
colon={false}
|
colon={false}
|
||||||
labelCol={{ md: { span: 8 } }}
|
labelCol={{ md: { span: 8 } }}
|
||||||
wrapperCol={{ md: { span: 14 } }}
|
wrapperCol={{ md: { span: 14 } }}
|
||||||
|
labelWrap
|
||||||
onValuesChange={onValuesChange}
|
onValuesChange={onValuesChange}
|
||||||
>
|
>
|
||||||
<Tabs
|
<Tabs
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ export default function SockoptForm({
|
|||||||
<Form.Item
|
<Form.Item
|
||||||
label={t('pages.inbounds.form.tcpWindowClamp')}
|
label={t('pages.inbounds.form.tcpWindowClamp')}
|
||||||
name={['streamSettings', 'sockopt', 'tcpWindowClamp']}
|
name={['streamSettings', 'sockopt', 'tcpWindowClamp']}
|
||||||
|
tooltip={t('pages.inbounds.form.tcpWindowClampHint')}
|
||||||
>
|
>
|
||||||
<InputNumber min={0} style={{ width: '100%' }} />
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -57,14 +57,18 @@ export const SockoptStreamSettingsSchema = z.object({
|
|||||||
tcpMptcp: z.boolean().default(false),
|
tcpMptcp: z.boolean().default(false),
|
||||||
penetrate: z.boolean().default(false),
|
penetrate: z.boolean().default(false),
|
||||||
domainStrategy: SockoptDomainStrategySchema.default('AsIs'),
|
domainStrategy: SockoptDomainStrategySchema.default('AsIs'),
|
||||||
tcpMaxSeg: z.number().int().min(0).default(1440),
|
// 0 = omit on the wire; xray-core skips sockopt fields <= 0 and uses OS defaults.
|
||||||
|
// Non-zero defaults here previously came from the xray docs *example* (clamp 600,
|
||||||
|
// maxSeg 1440, userTimeout 10000) and were written into every config when the
|
||||||
|
// panel sockopt switch was enabled, throttling long-haul links.
|
||||||
|
tcpMaxSeg: z.number().int().min(0).default(0),
|
||||||
dialerProxy: z.string().default(''),
|
dialerProxy: z.string().default(''),
|
||||||
tcpKeepAliveInterval: z.number().int().min(0).default(45),
|
tcpKeepAliveInterval: z.number().int().min(0).default(0),
|
||||||
tcpKeepAliveIdle: z.number().int().min(0).default(45),
|
tcpKeepAliveIdle: z.number().int().min(0).default(0),
|
||||||
tcpUserTimeout: z.number().int().min(0).default(10000),
|
tcpUserTimeout: z.number().int().min(0).default(0),
|
||||||
tcpcongestion: TcpCongestionSchema.default('bbr'),
|
tcpcongestion: TcpCongestionSchema.default('bbr'),
|
||||||
V6Only: z.boolean().default(false),
|
V6Only: z.boolean().default(false),
|
||||||
tcpWindowClamp: z.number().int().min(0).default(600),
|
tcpWindowClamp: z.number().int().min(0).default(0),
|
||||||
interface: z.string().default(''),
|
interface: z.string().default(''),
|
||||||
trustedXForwardedFor: z.array(z.string()).default([]),
|
trustedXForwardedFor: z.array(z.string()).default([]),
|
||||||
addressPortStrategy: AddressPortStrategySchema.default('none'),
|
addressPortStrategy: AddressPortStrategySchema.default('none'),
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ exports[`SockoptStreamSettingsSchema fixtures > parses defaults byte-stably 1`]
|
|||||||
"mark": 0,
|
"mark": 0,
|
||||||
"penetrate": false,
|
"penetrate": false,
|
||||||
"tcpFastOpen": false,
|
"tcpFastOpen": false,
|
||||||
"tcpKeepAliveIdle": 45,
|
"tcpKeepAliveIdle": 0,
|
||||||
"tcpKeepAliveInterval": 45,
|
"tcpKeepAliveInterval": 0,
|
||||||
"tcpMaxSeg": 1440,
|
"tcpMaxSeg": 0,
|
||||||
"tcpMptcp": false,
|
"tcpMptcp": false,
|
||||||
"tcpUserTimeout": 10000,
|
"tcpUserTimeout": 0,
|
||||||
"tcpWindowClamp": 600,
|
"tcpWindowClamp": 0,
|
||||||
"tcpcongestion": "bbr",
|
"tcpcongestion": "bbr",
|
||||||
"tproxy": "off",
|
"tproxy": "off",
|
||||||
"trustedXForwardedFor": [],
|
"trustedXForwardedFor": [],
|
||||||
@@ -87,12 +87,12 @@ exports[`SockoptStreamSettingsSchema fixtures > parses tproxy byte-stably 1`] =
|
|||||||
"mark": 255,
|
"mark": 255,
|
||||||
"penetrate": true,
|
"penetrate": true,
|
||||||
"tcpFastOpen": false,
|
"tcpFastOpen": false,
|
||||||
"tcpKeepAliveIdle": 45,
|
"tcpKeepAliveIdle": 0,
|
||||||
"tcpKeepAliveInterval": 45,
|
"tcpKeepAliveInterval": 0,
|
||||||
"tcpMaxSeg": 1440,
|
"tcpMaxSeg": 0,
|
||||||
"tcpMptcp": false,
|
"tcpMptcp": false,
|
||||||
"tcpUserTimeout": 10000,
|
"tcpUserTimeout": 0,
|
||||||
"tcpWindowClamp": 600,
|
"tcpWindowClamp": 0,
|
||||||
"tcpcongestion": "bbr",
|
"tcpcongestion": "bbr",
|
||||||
"tproxy": "tproxy",
|
"tproxy": "tproxy",
|
||||||
"trustedXForwardedFor": [],
|
"trustedXForwardedFor": [],
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { formValuesToWirePayload } from '@/lib/xray/inbound-form-adapter';
|
||||||
|
import { formValuesToWirePayload as outboundToWire } from '@/lib/xray/outbound-form-adapter';
|
||||||
|
import {
|
||||||
|
normalizeSockoptForWire,
|
||||||
|
normalizeStreamSettingsForWire,
|
||||||
|
normalizeXhttpForWire,
|
||||||
|
validateRealityTarget,
|
||||||
|
} from '@/lib/xray/stream-wire-normalize';
|
||||||
|
import type { InboundFormValues } from '@/schemas/forms/inbound-form';
|
||||||
|
|
||||||
|
describe('validateRealityTarget', () => {
|
||||||
|
it('accepts host:port and bare port', () => {
|
||||||
|
expect(validateRealityTarget('play.google.com:443')).toBeUndefined();
|
||||||
|
expect(validateRealityTarget('443')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects host without port', () => {
|
||||||
|
expect(validateRealityTarget('play.google.com')).toBe('pages.inbounds.form.realityTargetNeedsPort');
|
||||||
|
expect(validateRealityTarget('')).toBe('pages.inbounds.form.realityTargetRequired');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizeXhttpForWire stream-one', () => {
|
||||||
|
it('drops packet-up and stream-up-only fields on inbound', () => {
|
||||||
|
const out = normalizeXhttpForWire({
|
||||||
|
path: '/app',
|
||||||
|
host: 'play.google.com',
|
||||||
|
mode: 'stream-one',
|
||||||
|
xPaddingBytes: '100-1000',
|
||||||
|
scMaxEachPostBytes: '1000000',
|
||||||
|
scMinPostsIntervalMs: '30',
|
||||||
|
scMaxBufferedPosts: 30,
|
||||||
|
scStreamUpServerSecs: '20-80',
|
||||||
|
enableXmux: false,
|
||||||
|
headers: {},
|
||||||
|
}, 'inbound');
|
||||||
|
|
||||||
|
expect(out).toMatchObject({
|
||||||
|
path: '/app',
|
||||||
|
host: 'play.google.com',
|
||||||
|
mode: 'stream-one',
|
||||||
|
xPaddingBytes: '100-1000',
|
||||||
|
});
|
||||||
|
expect(out).not.toHaveProperty('scMaxEachPostBytes');
|
||||||
|
expect(out).not.toHaveProperty('scMinPostsIntervalMs');
|
||||||
|
expect(out).not.toHaveProperty('scMaxBufferedPosts');
|
||||||
|
expect(out).not.toHaveProperty('scStreamUpServerSecs');
|
||||||
|
expect(out).not.toHaveProperty('enableXmux');
|
||||||
|
expect(out).not.toHaveProperty('headers');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps xmux on outbound stream-one', () => {
|
||||||
|
const out = normalizeXhttpForWire({
|
||||||
|
path: '/app',
|
||||||
|
mode: 'stream-one',
|
||||||
|
xPaddingBytes: '100-1000',
|
||||||
|
xmux: { maxConcurrency: '16-32' },
|
||||||
|
scMaxEachPostBytes: '1000000',
|
||||||
|
}, 'outbound');
|
||||||
|
|
||||||
|
expect(out.xmux).toEqual({ maxConcurrency: '16-32' });
|
||||||
|
expect(out).not.toHaveProperty('scMaxEachPostBytes');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizeSockoptForWire', () => {
|
||||||
|
it('omits doc-example defaults that throttle throughput', () => {
|
||||||
|
const out = normalizeSockoptForWire({
|
||||||
|
tcpWindowClamp: 0,
|
||||||
|
tcpMaxSeg: 0,
|
||||||
|
tcpUserTimeout: 0,
|
||||||
|
tcpFastOpen: true,
|
||||||
|
tcpcongestion: 'bbr',
|
||||||
|
domainStrategy: 'AsIs',
|
||||||
|
tproxy: 'off',
|
||||||
|
mark: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(out).toEqual({
|
||||||
|
tcpFastOpen: true,
|
||||||
|
tcpcongestion: 'bbr',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves happyEyeballs on freedom-style outbound', () => {
|
||||||
|
const out = normalizeSockoptForWire({
|
||||||
|
domainStrategy: 'UseIP',
|
||||||
|
happyEyeballs: {
|
||||||
|
tryDelayMs: 150,
|
||||||
|
prioritizeIPv6: true,
|
||||||
|
interleave: 1,
|
||||||
|
maxConcurrentTry: 4,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(out?.happyEyeballs).toMatchObject({
|
||||||
|
tryDelayMs: 150,
|
||||||
|
prioritizeIPv6: true,
|
||||||
|
});
|
||||||
|
expect(out?.domainStrategy).toBe('UseIP');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizeStreamSettingsForWire reality', () => {
|
||||||
|
it('preserves the nested client settings on inbound (share links read publicKey from there)', () => {
|
||||||
|
const out = normalizeStreamSettingsForWire({
|
||||||
|
network: 'xhttp',
|
||||||
|
security: 'reality',
|
||||||
|
realitySettings: {
|
||||||
|
target: 'play.google.com:443',
|
||||||
|
privateKey: 'priv',
|
||||||
|
serverNames: ['play.google.com'],
|
||||||
|
shortIds: ['abcd'],
|
||||||
|
settings: {
|
||||||
|
publicKey: 'pub',
|
||||||
|
fingerprint: 'chrome',
|
||||||
|
spiderX: '/',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, { side: 'inbound' });
|
||||||
|
|
||||||
|
const reality = out.realitySettings as Record<string, unknown>;
|
||||||
|
expect(reality.target).toBe('play.google.com:443');
|
||||||
|
expect(reality.privateKey).toBe('priv');
|
||||||
|
const settings = reality.settings as Record<string, unknown>;
|
||||||
|
expect(settings.publicKey).toBe('pub');
|
||||||
|
expect(settings.spiderX).toBe('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes client realitySettings through unchanged on outbound', () => {
|
||||||
|
const out = normalizeStreamSettingsForWire({
|
||||||
|
network: 'xhttp',
|
||||||
|
security: 'reality',
|
||||||
|
realitySettings: {
|
||||||
|
publicKey: 'pub',
|
||||||
|
fingerprint: 'chrome',
|
||||||
|
serverName: 'play.google.com',
|
||||||
|
shortId: 'abcd',
|
||||||
|
spiderX: '/x',
|
||||||
|
},
|
||||||
|
}, { side: 'outbound' });
|
||||||
|
|
||||||
|
const reality = out.realitySettings as Record<string, unknown>;
|
||||||
|
expect(reality.publicKey).toBe('pub');
|
||||||
|
expect(reality.serverName).toBe('play.google.com');
|
||||||
|
expect(reality.spiderX).toBe('/x');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('inbound formValuesToWirePayload integration', () => {
|
||||||
|
it('emits lean stream-one xhttp + sockopt on save', () => {
|
||||||
|
const values = {
|
||||||
|
remark: 't',
|
||||||
|
enable: true,
|
||||||
|
port: 443,
|
||||||
|
listen: '0.0.0.0',
|
||||||
|
tag: 'in-443',
|
||||||
|
expiryTime: 0,
|
||||||
|
sniffing: { enabled: false },
|
||||||
|
up: 0,
|
||||||
|
down: 0,
|
||||||
|
total: 0,
|
||||||
|
trafficReset: 'never',
|
||||||
|
lastTrafficResetTime: 0,
|
||||||
|
nodeId: null,
|
||||||
|
protocol: 'vless',
|
||||||
|
settings: { clients: [{ id: '7eeb09ed-ae97-400d-a1ce-2485fb904407', email: 'n' }], decryption: 'none' },
|
||||||
|
streamSettings: {
|
||||||
|
network: 'xhttp',
|
||||||
|
security: 'reality',
|
||||||
|
realitySettings: {
|
||||||
|
target: 'play.google.com:443',
|
||||||
|
privateKey: 'priv',
|
||||||
|
serverNames: ['play.google.com'],
|
||||||
|
shortIds: ['44003d86dc1e'],
|
||||||
|
settings: { publicKey: 'pub', fingerprint: 'chrome', spiderX: '/' },
|
||||||
|
},
|
||||||
|
xhttpSettings: {
|
||||||
|
path: '/app',
|
||||||
|
host: 'play.google.com',
|
||||||
|
mode: 'stream-one',
|
||||||
|
xPaddingBytes: '100-1000',
|
||||||
|
scMaxEachPostBytes: '1000000',
|
||||||
|
scMinPostsIntervalMs: '30',
|
||||||
|
enableXmux: false,
|
||||||
|
},
|
||||||
|
sockopt: {
|
||||||
|
tcpWindowClamp: 0,
|
||||||
|
tcpMaxSeg: 0,
|
||||||
|
tcpUserTimeout: 0,
|
||||||
|
tcpFastOpen: true,
|
||||||
|
tcpcongestion: 'bbr',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as InboundFormValues;
|
||||||
|
|
||||||
|
const payload = formValuesToWirePayload(values);
|
||||||
|
const stream = JSON.parse(payload.streamSettings) as Record<string, unknown>;
|
||||||
|
const xhttp = stream.xhttpSettings as Record<string, unknown>;
|
||||||
|
const sockopt = stream.sockopt as Record<string, unknown>;
|
||||||
|
const reality = stream.realitySettings as Record<string, unknown>;
|
||||||
|
|
||||||
|
expect(xhttp).not.toHaveProperty('scMaxEachPostBytes');
|
||||||
|
expect(sockopt).not.toHaveProperty('tcpWindowClamp');
|
||||||
|
expect(sockopt.tcpFastOpen).toBe(true);
|
||||||
|
const realitySettings = reality.settings as Record<string, unknown>;
|
||||||
|
expect(realitySettings.publicKey).toBe('pub');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('freedom outbound sockopt wire payload', () => {
|
||||||
|
it('preserves happyEyeballs on direct freedom outbound', () => {
|
||||||
|
const wire = outboundToWire({
|
||||||
|
protocol: 'freedom',
|
||||||
|
tag: 'direct',
|
||||||
|
settings: { domainStrategy: 'UseIP' },
|
||||||
|
streamSettings: {
|
||||||
|
sockopt: {
|
||||||
|
domainStrategy: 'UseIP',
|
||||||
|
happyEyeballs: {
|
||||||
|
tryDelayMs: 150,
|
||||||
|
prioritizeIPv6: true,
|
||||||
|
interleave: 1,
|
||||||
|
maxConcurrentTry: 4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as Parameters<typeof outboundToWire>[0]);
|
||||||
|
|
||||||
|
expect(wire.streamSettings).toMatchObject({
|
||||||
|
sockopt: {
|
||||||
|
domainStrategy: 'UseIP',
|
||||||
|
happyEyeballs: {
|
||||||
|
tryDelayMs: 150,
|
||||||
|
prioritizeIPv6: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -90,6 +90,22 @@ func isLoopbackHost(host string) bool {
|
|||||||
return ip != nil && ip.IsLoopback()
|
return ip != nil && ip.IsLoopback()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listenIsInternalOnly reports whether a bind address is reachable only from
|
||||||
|
// the same host — a loopback IP or a unix-domain socket. Such an inbound can't
|
||||||
|
// be dialed directly by a remote client, so when it is the child side of a
|
||||||
|
// fallback its share link must be projected through the master. A public or
|
||||||
|
// wildcard listen (""/0.0.0.0/::) is reachable on its own port and advertises
|
||||||
|
// itself.
|
||||||
|
func listenIsInternalOnly(listen string) bool {
|
||||||
|
if listen == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if listen[0] == '@' || listen[0] == '/' {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return isLoopbackHost(listen)
|
||||||
|
}
|
||||||
|
|
||||||
// GetSubs retrieves subscription links for a given subscription ID and host.
|
// GetSubs retrieves subscription links for a given subscription ID and host.
|
||||||
func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int64, xray.ClientTraffic, error) {
|
func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int64, xray.ClientTraffic, error) {
|
||||||
s.PrepareForRequest(host)
|
s.PrepareForRequest(host)
|
||||||
@@ -260,10 +276,20 @@ func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error)
|
|||||||
// Returns true when a projection happened; sub services call this before
|
// Returns true when a projection happened; sub services call this before
|
||||||
// generating links so a child VLESS-WS bound to 127.0.0.1 emits the
|
// generating links so a child VLESS-WS bound to 127.0.0.1 emits the
|
||||||
// master's :443 + TLS state instead of its own loopback endpoint.
|
// master's :443 + TLS state instead of its own loopback endpoint.
|
||||||
|
//
|
||||||
|
// Projection only applies to a child that is not directly reachable on its
|
||||||
|
// own listen (loopback or a unix-domain socket). An inbound on a public or
|
||||||
|
// wildcard listen is reachable on its own port, so it advertises its own
|
||||||
|
// port + security even when a stale fallback rule still names it as a child —
|
||||||
|
// otherwise its share link would leak the master's port and Reality/TLS
|
||||||
|
// settings (#4987).
|
||||||
func (s *SubService) projectThroughFallbackMaster(inbound *model.Inbound) bool {
|
func (s *SubService) projectThroughFallbackMaster(inbound *model.Inbound) bool {
|
||||||
if inbound == nil {
|
if inbound == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if !listenIsInternalOnly(inbound.Listen) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
var master *model.Inbound
|
var master *model.Inbound
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,25 @@ func TestIsRoutableHost(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListenIsInternalOnly(t *testing.T) {
|
||||||
|
// Reachable only from the same host -> a fallback child here must be
|
||||||
|
// projected through its master.
|
||||||
|
internalOnly := []string{"127.0.0.1", "127.0.0.2", "::1", "[::1]", "@fallback", "/run/x.sock"}
|
||||||
|
for _, v := range internalOnly {
|
||||||
|
if !listenIsInternalOnly(v) {
|
||||||
|
t.Fatalf("listenIsInternalOnly(%q) = false, want true", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Directly reachable on its own port -> never projected, even if a stale
|
||||||
|
// fallback rule names it as a child (#4987).
|
||||||
|
reachable := []string{"", "0.0.0.0", "::", "::0", "1.2.3.4", "10.0.0.5", "192.168.1.10", "vpn.example.com"}
|
||||||
|
for _, v := range reachable {
|
||||||
|
if listenIsInternalOnly(v) {
|
||||||
|
t.Fatalf("listenIsInternalOnly(%q) = true, want false", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveInboundAddress(t *testing.T) {
|
func TestResolveInboundAddress(t *testing.T) {
|
||||||
const reqHost = "sub.example.com"
|
const reqHost = "sub.example.com"
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import (
|
|||||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v3/logger"
|
"github.com/mhsanaei/3x-ui/v3/logger"
|
||||||
"github.com/mhsanaei/3x-ui/v3/xray"
|
"github.com/mhsanaei/3x-ui/v3/xray"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// IPWithTimestamp tracks an IP address with its last seen timestamp
|
// IPWithTimestamp tracks an IP address with its last seen timestamp
|
||||||
@@ -184,6 +186,22 @@ func (j *CheckClientIpJob) processLogFile(enforce bool) bool {
|
|||||||
shouldCleanLog := false
|
shouldCleanLog := false
|
||||||
for email, ipTimestamps := range inboundClientIps {
|
for email, ipTimestamps := range inboundClientIps {
|
||||||
|
|
||||||
|
// The access log can still reference a client that was just renamed
|
||||||
|
// or deleted; its email no longer matches any inbound. Skip it (and
|
||||||
|
// drop any orphaned tracking row) instead of recreating a row and
|
||||||
|
// logging an ERROR every run until the log rotates out the old email
|
||||||
|
// (#4963).
|
||||||
|
inbound, err := j.getInboundByEmail(email)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
logger.Debugf("[LimitIP] skipping stale access-log email %q (renamed or deleted)", email)
|
||||||
|
j.delInboundClientIps(email)
|
||||||
|
} else {
|
||||||
|
j.checkError(err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Convert to IPWithTimestamp slice
|
// Convert to IPWithTimestamp slice
|
||||||
ipsWithTime := make([]IPWithTimestamp, 0, len(ipTimestamps))
|
ipsWithTime := make([]IPWithTimestamp, 0, len(ipTimestamps))
|
||||||
for ip, timestamp := range ipTimestamps {
|
for ip, timestamp := range ipTimestamps {
|
||||||
@@ -196,7 +214,7 @@ func (j *CheckClientIpJob) processLogFile(enforce bool) bool {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, email, ipsWithTime, enforce) || shouldCleanLog
|
shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, inbound, email, ipsWithTime, enforce) || shouldCleanLog
|
||||||
}
|
}
|
||||||
|
|
||||||
return shouldCleanLog
|
return shouldCleanLog
|
||||||
@@ -311,14 +329,17 @@ func (j *CheckClientIpJob) addInboundClientIps(clientEmail string, ipsWithTime [
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.InboundClientIps, clientEmail string, newIpsWithTime []IPWithTimestamp, enforce bool) bool {
|
// delInboundClientIps drops the inbound_client_ips tracking row for an email
|
||||||
// Get the inbound configuration
|
// that no longer maps to any inbound (a renamed or deleted client), so stale
|
||||||
inbound, err := j.getInboundByEmail(clientEmail)
|
// access-log entries don't keep a ghost row alive (#4963).
|
||||||
if err != nil {
|
func (j *CheckClientIpJob) delInboundClientIps(clientEmail string) {
|
||||||
logger.Errorf("failed to fetch inbound settings for email %s: %s", clientEmail, err)
|
db := database.GetDB()
|
||||||
return false
|
if err := db.Where("client_email = ?", clientEmail).Delete(&model.InboundClientIps{}).Error; err != nil {
|
||||||
|
j.checkError(err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.InboundClientIps, inbound *model.Inbound, clientEmail string, newIpsWithTime []IPWithTimestamp, enforce bool) bool {
|
||||||
if inbound.Settings == "" {
|
if inbound.Settings == "" {
|
||||||
logger.Debug("wrong data:", inbound)
|
logger.Debug("wrong data:", inbound)
|
||||||
return false
|
return false
|
||||||
@@ -411,7 +432,7 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
|
|||||||
inboundClientIps.Ips = string(jsonIps)
|
inboundClientIps.Ips = string(jsonIps)
|
||||||
|
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
err = db.Save(inboundClientIps).Error
|
err := db.Save(inboundClientIps).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("failed to save inboundClientIps:", err)
|
logger.Error("failed to save inboundClientIps:", err)
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -195,7 +195,11 @@ func TestUpdateInboundClientIps_LiveIpNotBannedByStillFreshHistoricals(t *testin
|
|||||||
{IP: "128.71.1.1", Timestamp: now},
|
{IP: "128.71.1.1", Timestamp: now},
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldCleanLog := j.updateInboundClientIps(row, email, live, true)
|
inbound, err := j.getInboundByEmail(email)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getInboundByEmail: %v", err)
|
||||||
|
}
|
||||||
|
shouldCleanLog := j.updateInboundClientIps(row, inbound, email, live, true)
|
||||||
|
|
||||||
if shouldCleanLog {
|
if shouldCleanLog {
|
||||||
t.Fatalf("shouldCleanLog must be false, nothing should have been banned with 1 live ip under limit 3")
|
t.Fatalf("shouldCleanLog must be false, nothing should have been banned with 1 live ip under limit 3")
|
||||||
@@ -244,7 +248,11 @@ func TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned(t *testing.T) {
|
|||||||
{IP: "192.0.2.9", Timestamp: now},
|
{IP: "192.0.2.9", Timestamp: now},
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldCleanLog := j.updateInboundClientIps(row, email, live, true)
|
inbound, err := j.getInboundByEmail(email)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getInboundByEmail: %v", err)
|
||||||
|
}
|
||||||
|
shouldCleanLog := j.updateInboundClientIps(row, inbound, email, live, true)
|
||||||
|
|
||||||
if !shouldCleanLog {
|
if !shouldCleanLog {
|
||||||
t.Fatalf("shouldCleanLog must be true when the live set exceeds the limit")
|
t.Fatalf("shouldCleanLog must be true when the live set exceeds the limit")
|
||||||
@@ -321,6 +329,32 @@ func TestRun_CollectsIpsWithoutLimit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #4963: a stale access-log entry for a renamed/deleted client (its email no
|
||||||
|
// longer maps to any inbound) must not create or resurrect an
|
||||||
|
// inbound_client_ips row, and must drop any orphan left behind — instead of
|
||||||
|
// spamming "failed to fetch inbound settings" every run.
|
||||||
|
func TestRun_StaleAccessLogEmailIsSkippedAndOrphanDropped(t *testing.T) {
|
||||||
|
setupIntegrationDB(t)
|
||||||
|
t.Setenv("XUI_ENABLE_FAIL2BAN", "true")
|
||||||
|
fakeFail2BanClient(t)
|
||||||
|
|
||||||
|
const staleEmail = "renamed-away"
|
||||||
|
// No inbound references staleEmail. Pre-seed an orphan tracking row to
|
||||||
|
// confirm the job removes it rather than leaving it to error forever.
|
||||||
|
seedClientIps(t, staleEmail, []IPWithTimestamp{{IP: "203.0.113.5", Timestamp: time.Now().Unix()}})
|
||||||
|
writeXrayAccessLog(t, staleEmail, "203.0.113.5")
|
||||||
|
|
||||||
|
NewCheckClientIpJob().Run()
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
if err := database.GetDB().Model(&model.InboundClientIps{}).Where("client_email = ?", staleEmail).Count(&count).Error; err != nil {
|
||||||
|
t.Fatalf("count InboundClientIps: %v", err)
|
||||||
|
}
|
||||||
|
if count != 0 {
|
||||||
|
t.Fatalf("stale-email orphan row should be deleted, got %d row(s)", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// readIpLimitLogPath reads the 3xipl.log path the same way the job
|
// readIpLimitLogPath reads the 3xipl.log path the same way the job
|
||||||
// does via xray.GetIPLimitLogPath but without importing xray here
|
// does via xray.GetIPLimitLogPath but without importing xray here
|
||||||
// just for the path helper (which would pull a lot more deps into the
|
// just for the path helper (which would pull a lot more deps into the
|
||||||
|
|||||||
@@ -1193,6 +1193,11 @@ func (s *InboundService) updateClientTraffics(tx *gorm.DB, oldInbound *model.Inb
|
|||||||
if err := s.DelClientStat(tx, email); err != nil {
|
if err := s.DelClientStat(tx, email); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// Keep inbound_client_ips in sync when the inbound edit drops an
|
||||||
|
// email, so the IP-limit job doesn't keep a ghost tracking row (#4963).
|
||||||
|
if err := s.DelClientIPs(tx, email); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for i := range newClients {
|
for i := range newClients {
|
||||||
email := newClients[i].Email
|
email := newClients[i].Email
|
||||||
@@ -1760,7 +1765,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
|||||||
// from the node arriving after a central disable would otherwise
|
// from the node arriving after a central disable would otherwise
|
||||||
// overwrite enable=false back to true, letting the client accumulate
|
// overwrite enable=false back to true, letting the client accumulate
|
||||||
// far more traffic than their limit before being disabled again.
|
// far more traffic than their limit before being disabled again.
|
||||||
enableExpr := "CASE WHEN ? = 0 THEN 0 ELSE enable END"
|
enableExpr := "enable AND ?"
|
||||||
if err := tx.Exec(
|
if err := tx.Exec(
|
||||||
fmt.Sprintf(
|
fmt.Sprintf(
|
||||||
`UPDATE client_traffics
|
`UPDATE client_traffics
|
||||||
|
|||||||
@@ -560,6 +560,7 @@
|
|||||||
"tcpMaxSeg": "TCP Max Seg",
|
"tcpMaxSeg": "TCP Max Seg",
|
||||||
"tcpUserTimeout": "TCP User Timeout",
|
"tcpUserTimeout": "TCP User Timeout",
|
||||||
"tcpWindowClamp": "TCP Window Clamp",
|
"tcpWindowClamp": "TCP Window Clamp",
|
||||||
|
"tcpWindowClampHint": "Leave 0 to use the OS default. Non-zero values cap the advertised TCP receive window; values like 600 (from the Xray docs example) can collapse throughput on high-latency links.",
|
||||||
"tcpFastOpen": "TCP Fast Open",
|
"tcpFastOpen": "TCP Fast Open",
|
||||||
"multipathTcp": "Multipath TCP",
|
"multipathTcp": "Multipath TCP",
|
||||||
"penetrate": "Penetrate",
|
"penetrate": "Penetrate",
|
||||||
@@ -598,6 +599,10 @@
|
|||||||
"minClientVer": "Min Client Ver",
|
"minClientVer": "Min Client Ver",
|
||||||
"maxClientVer": "Max Client Ver",
|
"maxClientVer": "Max Client Ver",
|
||||||
"shortIds": "Short IDs",
|
"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",
|
||||||
|
"realityTargetNeedsPort": "REALITY target must include a port (e.g. example.com:443)",
|
||||||
|
"realityTargetInvalidPort": "REALITY target has an invalid port",
|
||||||
"spiderX": "SpiderX",
|
"spiderX": "SpiderX",
|
||||||
"getNewCert": "Get New Cert",
|
"getNewCert": "Get New Cert",
|
||||||
"mldsa65Seed": "mldsa65 Seed",
|
"mldsa65Seed": "mldsa65 Seed",
|
||||||
@@ -1178,6 +1183,9 @@
|
|||||||
"TemplateDesc": "The final Xray config file will be generated based on this template.",
|
"TemplateDesc": "The final Xray config file will be generated based on this template.",
|
||||||
"FreedomStrategy": "Freedom Protocol Strategy",
|
"FreedomStrategy": "Freedom Protocol Strategy",
|
||||||
"FreedomStrategyDesc": "Set the output strategy for the network in the Freedom Protocol.",
|
"FreedomStrategyDesc": "Set the output strategy for the network in the Freedom Protocol.",
|
||||||
|
"FreedomHappyEyeballs": "Freedom Happy Eyeballs (IPv4/IPv6)",
|
||||||
|
"FreedomHappyEyeballsDesc": "Dual-stack dialing for the direct (freedom) outbound — useful on exit servers with both IPv4 and IPv6.",
|
||||||
|
"FreedomHappyEyeballsTryDelayDesc": "Milliseconds before trying the alternate address family. 150–250 ms is a good starting point.",
|
||||||
"RoutingStrategy": "Overall Routing Strategy",
|
"RoutingStrategy": "Overall Routing Strategy",
|
||||||
"RoutingStrategyDesc": "Set the overall traffic routing strategy for resolving all requests.",
|
"RoutingStrategyDesc": "Set the overall traffic routing strategy for resolving all requests.",
|
||||||
"outboundTestUrl": "Outbound Test URL",
|
"outboundTestUrl": "Outbound Test URL",
|
||||||
|
|||||||
@@ -585,7 +585,7 @@
|
|||||||
"buildChain": "Build Chain",
|
"buildChain": "Build Chain",
|
||||||
"echKey": "ECH key",
|
"echKey": "ECH key",
|
||||||
"echConfig": "ECH config",
|
"echConfig": "ECH config",
|
||||||
"pinnedPeerCertSha256": "Закреплённый SHA-256 сертификата пира",
|
"pinnedPeerCertSha256": "SHA-256 сертификата пира",
|
||||||
"pinnedPeerCertSha256Tip": "SHA-256-хеши сертификата пира в виде шестнадцатеричной строки (напр. e8e2d3…), через запятую. Только для панели — не записывается в конфиг xray сервера, но включается в ссылки-приглашения, чтобы клиенты могли закрепить сертификат.",
|
"pinnedPeerCertSha256Tip": "SHA-256-хеши сертификата пира в виде шестнадцатеричной строки (напр. e8e2d3…), через запятую. Только для панели — не записывается в конфиг xray сервера, но включается в ссылки-приглашения, чтобы клиенты могли закрепить сертификат.",
|
||||||
"pinnedPeerCertSha256Placeholder": "шестнадцатеричный хеш(и), через запятую",
|
"pinnedPeerCertSha256Placeholder": "шестнадцатеричный хеш(и), через запятую",
|
||||||
"generateRandomPin": "Сгенерировать случайный хеш",
|
"generateRandomPin": "Сгенерировать случайный хеш",
|
||||||
|
|||||||
@@ -2795,6 +2795,7 @@ postgresql_menu() {
|
|||||||
echo -e "${green}\t6.${plain} Restart PostgreSQL"
|
echo -e "${green}\t6.${plain} Restart PostgreSQL"
|
||||||
echo -e "${green}\t7.${plain} ${green}Enable${plain} Autostart on boot"
|
echo -e "${green}\t7.${plain} ${green}Enable${plain} Autostart on boot"
|
||||||
echo -e "${green}\t8.${plain} View PostgreSQL Log"
|
echo -e "${green}\t8.${plain} View PostgreSQL Log"
|
||||||
|
echo -e "${green}\t9.${plain} Convert SQLite ${green}.db <-> .dump${plain}"
|
||||||
echo -e "${green}\t0.${plain} Back to Main Menu"
|
echo -e "${green}\t0.${plain} Back to Main Menu"
|
||||||
read -rp "Choose an option: " choice
|
read -rp "Choose an option: " choice
|
||||||
case "$choice" in
|
case "$choice" in
|
||||||
@@ -2833,6 +2834,10 @@ postgresql_menu() {
|
|||||||
postgresql_log
|
postgresql_log
|
||||||
postgresql_menu
|
postgresql_menu
|
||||||
;;
|
;;
|
||||||
|
9)
|
||||||
|
migrate_db_prompt
|
||||||
|
postgresql_menu
|
||||||
|
;;
|
||||||
*)
|
*)
|
||||||
echo -e "${red}Invalid option. Please select a valid number.${plain}\n"
|
echo -e "${red}Invalid option. Please select a valid number.${plain}\n"
|
||||||
postgresql_menu
|
postgresql_menu
|
||||||
@@ -2840,6 +2845,99 @@ postgresql_menu() {
|
|||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Convert between the panel's SQLite database and a portable .dump (SQL text)
|
||||||
|
# file using the bundled x-ui binary. With no arguments it dumps the installed
|
||||||
|
# panel database; an optional second argument overrides the output path.
|
||||||
|
# x-ui migrateDB [file.db|file.dump] [output]
|
||||||
|
migrate_db() {
|
||||||
|
local input="$1" output="$2"
|
||||||
|
local default_db="/etc/x-ui/x-ui.db"
|
||||||
|
local bin="${xui_folder}/x-ui"
|
||||||
|
|
||||||
|
[[ -z "$input" ]] && input="$default_db"
|
||||||
|
|
||||||
|
if [[ ! -x "$bin" ]]; then
|
||||||
|
LOGE "x-ui binary not found at ${bin}. Is the panel installed?"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! "$bin" migrate-db -h 2>&1 | grep -q -- '-dump'; then
|
||||||
|
LOGE "This x-ui build does not support .db <-> .dump conversion yet."
|
||||||
|
LOGE "Update the panel first (x-ui update) to a version with 'migrate-db --dump/--restore'."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$input" ]]; then
|
||||||
|
LOGE "Input file not found: ${input}"
|
||||||
|
echo -e "Usage: ${green}x-ui migrateDB [file.db|file.dump] [output]${plain}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local mode
|
||||||
|
case "$input" in
|
||||||
|
*.db | *.sqlite | *.sqlite3)
|
||||||
|
mode="dump"
|
||||||
|
;;
|
||||||
|
*.dump | *.sql)
|
||||||
|
mode="restore"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
if head -c 16 "$input" | grep -q "SQLite format 3"; then
|
||||||
|
mode="dump"
|
||||||
|
else
|
||||||
|
mode="restore"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [[ "$mode" == "dump" ]]; then
|
||||||
|
[[ -z "$output" ]] && output="${input%.*}.dump"
|
||||||
|
if [[ -f "$output" ]]; then
|
||||||
|
confirm "Output ${output} already exists and will be overwritten. Continue?" "n" || return 0
|
||||||
|
fi
|
||||||
|
LOGI "Dumping SQLite database to SQL text:"
|
||||||
|
echo -e " ${green}${input}${plain} -> ${green}${output}${plain}"
|
||||||
|
if "$bin" migrate-db --src "$input" --dump "$output"; then
|
||||||
|
LOGI "Done. Wrote ${output}."
|
||||||
|
else
|
||||||
|
LOGE "Dump failed."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
[[ -z "$output" ]] && output="${input%.*}.db"
|
||||||
|
if [[ "$output" == "$default_db" ]] && check_status > /dev/null 2>&1; then
|
||||||
|
LOGE "Refusing to restore into the live database (${default_db}) while x-ui is running."
|
||||||
|
LOGE "Stop the panel first (x-ui stop) or choose a different output path."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ -f "$output" ]]; then
|
||||||
|
confirm "Output ${output} already exists and will be overwritten. Continue?" "n" || return 0
|
||||||
|
rm -f "$output"
|
||||||
|
fi
|
||||||
|
LOGI "Rebuilding SQLite database from SQL text:"
|
||||||
|
echo -e " ${green}${input}${plain} -> ${green}${output}${plain}"
|
||||||
|
if "$bin" migrate-db --restore "$input" --out "$output"; then
|
||||||
|
LOGI "Done. Created ${output}."
|
||||||
|
else
|
||||||
|
LOGE "Restore failed."
|
||||||
|
rm -f "$output"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Interactive wrapper around migrate_db for the menu: prompts for the paths and
|
||||||
|
# lets migrate_db auto-detect the direction.
|
||||||
|
migrate_db_prompt() {
|
||||||
|
local default_db="/etc/x-ui/x-ui.db"
|
||||||
|
local input output
|
||||||
|
echo -e "Convert between a SQLite ${green}.db${plain} and a portable ${green}.dump${plain} (direction auto-detected)."
|
||||||
|
read -rp "Input file [${default_db}]: " input
|
||||||
|
input="${input:-$default_db}"
|
||||||
|
read -rp "Output file (leave empty to auto-name next to input): " output
|
||||||
|
migrate_db "$input" "$output"
|
||||||
|
}
|
||||||
|
|
||||||
show_usage() {
|
show_usage() {
|
||||||
echo -e "┌────────────────────────────────────────────────────────────────┐
|
echo -e "┌────────────────────────────────────────────────────────────────┐
|
||||||
│ ${blue}x-ui control menu usages (subcommands):${plain} │
|
│ ${blue}x-ui control menu usages (subcommands):${plain} │
|
||||||
@@ -2857,6 +2955,7 @@ show_usage() {
|
|||||||
│ ${blue}x-ui banlog${plain} - Check Fail2ban ban logs │
|
│ ${blue}x-ui banlog${plain} - Check Fail2ban ban logs │
|
||||||
│ ${blue}x-ui update${plain} - Update │
|
│ ${blue}x-ui update${plain} - Update │
|
||||||
│ ${blue}x-ui update-all-geofiles${plain} - Update all geo files │
|
│ ${blue}x-ui update-all-geofiles${plain} - Update all geo files │
|
||||||
|
│ ${blue}x-ui migrateDB [file]${plain} - Convert .db <-> .dump (SQLite) │
|
||||||
│ ${blue}x-ui legacy${plain} - Legacy version │
|
│ ${blue}x-ui legacy${plain} - Legacy version │
|
||||||
│ ${blue}x-ui install${plain} - Install │
|
│ ${blue}x-ui install${plain} - Install │
|
||||||
│ ${blue}x-ui uninstall${plain} - Uninstall │
|
│ ${blue}x-ui uninstall${plain} - Uninstall │
|
||||||
@@ -3045,6 +3144,9 @@ if [[ $# > 0 ]]; then
|
|||||||
"update-all-geofiles")
|
"update-all-geofiles")
|
||||||
check_install 0 && update_all_geofiles 0 && restart 0
|
check_install 0 && update_all_geofiles 0 && restart 0
|
||||||
;;
|
;;
|
||||||
|
"migrateDB")
|
||||||
|
migrate_db "$2" "$3"
|
||||||
|
;;
|
||||||
*) show_usage ;;
|
*) show_usage ;;
|
||||||
esac
|
esac
|
||||||
else
|
else
|
||||||
|
|||||||
Reference in New Issue
Block a user