Compare commits

..

7 Commits

Author SHA1 Message Date
nima1024m 6ed6f57b5c fix(panel): normalize XHTTP/sockopt/Reality wire output and validate REALITY target (#4988)
* fix(panel): normalize XHTTP/sockopt/Reality wire output and validate REALITY target

Strip mode-specific XHTTP fields for stream-one, reset harmful sockopt defaults
to 0, split server/client Reality fields on save, validate target host:port in
the inbound form, and expose Happy Eyeballs for the direct freedom outbound.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(panel): keep REALITY public key on the wire, guard freedom noises

The REALITY server/client wire split deleted realitySettings.settings on save, but the panel stores the REALITY public key there and every share-link / subscription generator reads it back from that path (frontend inbound-link.ts, Go subService/subJsonService/subClashService). Stripping it produced empty pbk= links, breaking client connectivity after save+reload.

Revert the reality normalization (drop normalizeRealityForWire and the key sets), restore the inbound REALITY form fields (uTLS, spiderX, publicKey, mldsa65Verify) while keeping the new validated target field, and restore the mldsa65Verify clear handler.

Also guard freedomToWire against undefined noises/finalRules (same defensive treatment as the existing fragment guard, issue #4686) which the new freedom-outbound test surfaced as a crash. Tests now assert the public key is preserved.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
2026-06-06 02:40:32 +02:00
MHSanaei e409bc305d fix(iplimit): skip stale access-log emails after client rename/delete
The IP-limit job scrapes the Xray access log, which keeps lines tagged with a client's old email for up to a log-rotation cycle after a rename or delete. For each such email getInboundByEmail (settings LIKE %email%) found nothing, so the job logged 'failed to fetch inbound settings: record not found' every run and recreated an inbound_client_ips row for the dead email (rows reappeared even after manual deletion).

processLogFile now resolves the inbound once per email: if it maps to no inbound (gorm.ErrRecordNotFound) it logs at Debug, drops any orphan tracking row, and skips - so stale entries self-heal instead of spamming ERROR. The resolved inbound is passed into updateInboundClientIps, removing its internal lookup. updateClientTraffics also calls DelClientIPs alongside DelClientStat so a full inbound edit that drops an email doesn't leave a ghost row.

Closes #4963
2026-06-06 02:20:39 +02:00
MHSanaei 2b4e199a97 fix(sub): don't project public inbounds through a fallback master
A standalone inbound bound to a public/wildcard listen that still carried a stale inbound_fallbacks row had its share/subscription link rewritten with the master's port + Reality/TLS settings (keeping only its own transport), producing an unusable link that silently fails - the client connects but no traffic flows. The leak hit every backend link surface: subscription URL, JSON sub, Clash sub, and the panel Client Information link.

Gate projectThroughFallbackMaster on reachability: only project a child that is not directly reachable on its own listen (loopback or a unix-domain socket). A public or wildcard inbound advertises its own port + security regardless of any fallback row. Legit loopback/socket fallback children still project as before.

Closes #4987
2026-06-06 02:13:39 +02:00
MHSanaei 75bc6e8076 fix(inbound-form): wrap long labels and shorten RU pinned-cert label
Long TLS-tab labels overflowed their field in locales with wider strings (e.g. Russian 'Pinned Peer Cert SHA-256'). Add AntD labelWrap to the inbound and outbound form modals so any over-long label wraps onto a second line instead of overflowing, and shorten the Russian pinnedPeerCertSha256 label to fit.

Closes #4986
2026-06-06 01:53:46 +02:00
MHSanaei eeb19b7240 fix(node-sync): merge client enable with boolean AND for PostgreSQL
The per-client traffic merge built enable = CASE WHEN ? = 0 THEN 0 ELSE enable END, mixing an integer literal with the boolean enable column. PostgreSQL rejects this with SQLSTATE 42804, aborting every node traffic merge transaction every 5s and freezing all up/down/last_online accounting on Postgres main panels. Replace with enable AND ?, which is type-safe on Postgres (boolean AND boolean) and identical in semantics on SQLite: the node may only disable a client, never re-enable one the panel already disabled.

Closes #4964
2026-06-06 01:46:55 +02:00
MHSanaei 5b9db13e55 fix(finalmask): treat sudoku customTables as array of tables
customTables is the plural array form of customTable, so default it to [""] and edit it with a tags Select instead of binding a text Input to an array value.
2026-06-06 01:35:14 +02:00
Sanaei 0706b0b3a8 feat(x-ui.sh): add migrateDB command for SQLite .db <-> .dump (#4910)
* feat(x-ui.sh): add migrateDB command and menu for SQLite .db <-> .dump

Adds an "x-ui migrateDB <file>" subcommand and a PostgreSQL-menu option (9)
that convert between a SQLite .db and a portable .dump file. Direction is
auto-detected from the extension and delegated to the bundled binary
(x-ui migrate-db --dump/--restore), so no external sqlite3 client is needed.

Depends on the matching binary support, so it is only usable from the next
panel release.

* fix(x-ui.sh): address review feedback on migrateDB

Per Copilot review on PR #4910:
- Probe the bundled binary for migrate-db --dump support and fail with a clear
  upgrade message instead of a raw "flag not defined" error on old builds.
- Prompt before overwriting an existing .dump in dump mode (parity with restore).
- Refuse to restore into the live database path while x-ui is running, to avoid
  corrupting the running panel.
- Fix the usage/synopsis strings to show input is optional ([file] not <file>).
2026-06-05 11:28:11 +02:00
21 changed files with 841 additions and 37 deletions
@@ -29,7 +29,7 @@ function defaultTcpMaskSettings(type: string): Record<string, unknown> {
return { packets: '1-3', length: '', delay: '', maxSplit: '' };
case 'sudoku':
return {
password: '', ascii: '', customTable: '', customTables: '',
password: '', ascii: '', customTable: '', customTables: [''],
paddingMin: 0, paddingMax: 0,
};
case 'header-custom':
@@ -228,7 +228,9 @@ function TcpMaskItem({
<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="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']}>
<InputNumber min={0} />
</Form.Item>
@@ -10,6 +10,7 @@ import {
import type { StreamSettings } from '@/schemas/api/inbound';
import type { Sniffing } from '@/schemas/primitives';
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
// the typed InboundFormValues that Form.useForm<T> carries inside
@@ -279,10 +280,13 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP
if (Array.isArray(settingsPruned.clients)) {
settingsPruned.clients = normalizeClients(values.protocol, settingsPruned.clients);
}
const streamPruned = values.streamSettings
let streamPruned = values.streamSettings
? ((pruneEmpty(values.streamSettings) ?? {}) as Record<string, unknown>)
: undefined;
if (streamPruned) stripTlsCertUseFile(streamPruned);
if (streamPruned) {
streamPruned = normalizeStreamSettingsForWire(streamPruned, { side: 'inbound' });
stripTlsCertUseFile(streamPruned);
}
dropLegacyOptionalEmpties(settingsPruned, streamPruned);
const payload: WireInboundPayload = {
up: values.up,
@@ -1,4 +1,5 @@
import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
import { Wireguard } from '@/utils';
import type {
@@ -519,8 +520,8 @@ function freedomToWire(s: FreedomOutboundFormSettings) {
userLevel: s.userLevel || undefined,
proxyProtocol: s.proxyProtocol || undefined,
fragment: fragmentEnabled ? Object.fromEntries(fragmentEntries) : undefined,
noises: s.noises.length > 0 ? s.noises : undefined,
finalRules: s.finalRules.length > 0
noises: s.noises && s.noises.length > 0 ? s.noises : undefined,
finalRules: s.finalRules && s.finalRules.length > 0
? s.finalRules.map((r) => ({
action: r.action,
network: r.network || undefined,
@@ -588,7 +589,7 @@ function stripUiOnlyStreamFields(stream: unknown): Raw {
if (!xmuxEnabled) delete cleaned.xmux;
next.xhttpSettings = dropEmptyStrings(cleaned);
}
return next;
return normalizeStreamSettingsForWire(next, { side: 'outbound' }) as Raw;
}
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}
labelCol={{ sm: { span: 8 } }}
wrapperCol={{ sm: { span: 14 } }}
labelWrap
onValuesChange={onValuesChange}
>
<Tabs items={[
@@ -3,6 +3,7 @@ import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import { UTLS_FINGERPRINT } from '@/schemas/primitives';
import { validateRealityTarget } from '@/lib/xray/stream-wire-normalize';
interface RealityFormProps {
saving: boolean;
@@ -44,10 +45,24 @@ export default function RealityForm({
options={Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp }))}
/>
</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>
<Form.Item name={['streamSettings', 'realitySettings', 'target']} noStyle>
<Input style={{ width: 'calc(100% - 32px)' }} />
<Form.Item
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>
<Button icon={<ReloadOutlined />} onClick={randomizeRealityTarget} />
</Space.Compact>
@@ -60,6 +60,7 @@ export default function SockoptForm({
<Form.Item
name={['streamSettings', 'sockopt', 'tcpWindowClamp']}
label={t('pages.inbounds.form.tcpWindowClamp')}
tooltip={t('pages.inbounds.form.tcpWindowClampHint')}
>
<InputNumber min={0} />
</Form.Item>
@@ -10,6 +10,7 @@ import {
} from '@ant-design/icons';
import { OutboundDomainStrategies } from '@/schemas/primitives';
import { HappyEyeballsSchema } from '@/schemas/protocols/stream/sockopt';
import { SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from '@/pages/settings/catTabLabel';
@@ -84,6 +85,49 @@ export default function BasicsTab({
| { domainStrategy?: string }
| 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 log = (templateSettings?.log || {}) as Record<string, unknown>;
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
title={t('pages.xray.RoutingStrategy')}
description={t('pages.xray.RoutingStrategyDesc')}
@@ -350,6 +350,7 @@ export default function OutboundFormModal({
colon={false}
labelCol={{ md: { span: 8 } }}
wrapperCol={{ md: { span: 14 } }}
labelWrap
onValuesChange={onValuesChange}
>
<Tabs
@@ -171,6 +171,7 @@ export default function SockoptForm({
<Form.Item
label={t('pages.inbounds.form.tcpWindowClamp')}
name={['streamSettings', 'sockopt', 'tcpWindowClamp']}
tooltip={t('pages.inbounds.form.tcpWindowClampHint')}
>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
@@ -57,14 +57,18 @@ export const SockoptStreamSettingsSchema = z.object({
tcpMptcp: z.boolean().default(false),
penetrate: z.boolean().default(false),
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(''),
tcpKeepAliveInterval: z.number().int().min(0).default(45),
tcpKeepAliveIdle: z.number().int().min(0).default(45),
tcpUserTimeout: z.number().int().min(0).default(10000),
tcpKeepAliveInterval: z.number().int().min(0).default(0),
tcpKeepAliveIdle: z.number().int().min(0).default(0),
tcpUserTimeout: z.number().int().min(0).default(0),
tcpcongestion: TcpCongestionSchema.default('bbr'),
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(''),
trustedXForwardedFor: z.array(z.string()).default([]),
addressPortStrategy: AddressPortStrategySchema.default('none'),
@@ -12,12 +12,12 @@ exports[`SockoptStreamSettingsSchema fixtures > parses defaults byte-stably 1`]
"mark": 0,
"penetrate": false,
"tcpFastOpen": false,
"tcpKeepAliveIdle": 45,
"tcpKeepAliveInterval": 45,
"tcpMaxSeg": 1440,
"tcpKeepAliveIdle": 0,
"tcpKeepAliveInterval": 0,
"tcpMaxSeg": 0,
"tcpMptcp": false,
"tcpUserTimeout": 10000,
"tcpWindowClamp": 600,
"tcpUserTimeout": 0,
"tcpWindowClamp": 0,
"tcpcongestion": "bbr",
"tproxy": "off",
"trustedXForwardedFor": [],
@@ -87,12 +87,12 @@ exports[`SockoptStreamSettingsSchema fixtures > parses tproxy byte-stably 1`] =
"mark": 255,
"penetrate": true,
"tcpFastOpen": false,
"tcpKeepAliveIdle": 45,
"tcpKeepAliveInterval": 45,
"tcpMaxSeg": 1440,
"tcpKeepAliveIdle": 0,
"tcpKeepAliveInterval": 0,
"tcpMaxSeg": 0,
"tcpMptcp": false,
"tcpUserTimeout": 10000,
"tcpWindowClamp": 600,
"tcpUserTimeout": 0,
"tcpWindowClamp": 0,
"tcpcongestion": "bbr",
"tproxy": "tproxy",
"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,
},
},
});
});
});
+26
View File
@@ -90,6 +90,22 @@ func isLoopbackHost(host string) bool {
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.
func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int64, xray.ClientTraffic, error) {
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
// 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.
//
// 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 {
if inbound == nil {
return false
}
if !listenIsInternalOnly(inbound.Listen) {
return false
}
db := database.GetDB()
var master *model.Inbound
+19
View File
@@ -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) {
const reqHost = "sub.example.com"
+29 -8
View File
@@ -17,6 +17,8 @@ import (
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/logger"
"github.com/mhsanaei/3x-ui/v3/xray"
"gorm.io/gorm"
)
// IPWithTimestamp tracks an IP address with its last seen timestamp
@@ -184,6 +186,22 @@ func (j *CheckClientIpJob) processLogFile(enforce bool) bool {
shouldCleanLog := false
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
ipsWithTime := make([]IPWithTimestamp, 0, len(ipTimestamps))
for ip, timestamp := range ipTimestamps {
@@ -196,7 +214,7 @@ func (j *CheckClientIpJob) processLogFile(enforce bool) bool {
continue
}
shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, email, ipsWithTime, enforce) || shouldCleanLog
shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, inbound, email, ipsWithTime, enforce) || shouldCleanLog
}
return shouldCleanLog
@@ -311,14 +329,17 @@ func (j *CheckClientIpJob) addInboundClientIps(clientEmail string, ipsWithTime [
return nil
}
func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.InboundClientIps, clientEmail string, newIpsWithTime []IPWithTimestamp, enforce bool) bool {
// Get the inbound configuration
inbound, err := j.getInboundByEmail(clientEmail)
if err != nil {
logger.Errorf("failed to fetch inbound settings for email %s: %s", clientEmail, err)
return false
// delInboundClientIps drops the inbound_client_ips tracking row for an email
// that no longer maps to any inbound (a renamed or deleted client), so stale
// access-log entries don't keep a ghost row alive (#4963).
func (j *CheckClientIpJob) delInboundClientIps(clientEmail string) {
db := database.GetDB()
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 == "" {
logger.Debug("wrong data:", inbound)
return false
@@ -411,7 +432,7 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
inboundClientIps.Ips = string(jsonIps)
db := database.GetDB()
err = db.Save(inboundClientIps).Error
err := db.Save(inboundClientIps).Error
if err != nil {
logger.Error("failed to save inboundClientIps:", err)
return false
@@ -195,7 +195,11 @@ func TestUpdateInboundClientIps_LiveIpNotBannedByStillFreshHistoricals(t *testin
{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 {
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},
}
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 {
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
// does via xray.GetIPLimitLogPath but without importing xray here
// just for the path helper (which would pull a lot more deps into the
+6 -1
View File
@@ -1193,6 +1193,11 @@ func (s *InboundService) updateClientTraffics(tx *gorm.DB, oldInbound *model.Inb
if err := s.DelClientStat(tx, email); err != nil {
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 {
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
// overwrite enable=false back to true, letting the client accumulate
// 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(
fmt.Sprintf(
`UPDATE client_traffics
+8
View File
@@ -560,6 +560,7 @@
"tcpMaxSeg": "TCP Max Seg",
"tcpUserTimeout": "TCP User Timeout",
"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",
"multipathTcp": "Multipath TCP",
"penetrate": "Penetrate",
@@ -598,6 +599,10 @@
"minClientVer": "Min Client Ver",
"maxClientVer": "Max Client Ver",
"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",
"getNewCert": "Get New Cert",
"mldsa65Seed": "mldsa65 Seed",
@@ -1178,6 +1183,9 @@
"TemplateDesc": "The final Xray config file will be generated based on this template.",
"FreedomStrategy": "Freedom Protocol Strategy",
"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",
"RoutingStrategyDesc": "Set the overall traffic routing strategy for resolving all requests.",
"outboundTestUrl": "Outbound Test URL",
+1 -1
View File
@@ -585,7 +585,7 @@
"buildChain": "Build Chain",
"echKey": "ECH key",
"echConfig": "ECH config",
"pinnedPeerCertSha256": "Закреплённый SHA-256 сертификата пира",
"pinnedPeerCertSha256": "SHA-256 сертификата пира",
"pinnedPeerCertSha256Tip": "SHA-256-хеши сертификата пира в виде шестнадцатеричной строки (напр. e8e2d3…), через запятую. Только для панели — не записывается в конфиг xray сервера, но включается в ссылки-приглашения, чтобы клиенты могли закрепить сертификат.",
"pinnedPeerCertSha256Placeholder": "шестнадцатеричный хеш(и), через запятую",
"generateRandomPin": "Сгенерировать случайный хеш",
+102
View File
@@ -2795,6 +2795,7 @@ postgresql_menu() {
echo -e "${green}\t6.${plain} Restart PostgreSQL"
echo -e "${green}\t7.${plain} ${green}Enable${plain} Autostart on boot"
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"
read -rp "Choose an option: " choice
case "$choice" in
@@ -2833,6 +2834,10 @@ postgresql_menu() {
postgresql_log
postgresql_menu
;;
9)
migrate_db_prompt
postgresql_menu
;;
*)
echo -e "${red}Invalid option. Please select a valid number.${plain}\n"
postgresql_menu
@@ -2840,6 +2845,99 @@ postgresql_menu() {
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() {
echo -e "┌────────────────────────────────────────────────────────────────┐
│ ${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 update${plain} - Update │
│ ${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 install${plain} - Install │
│ ${blue}x-ui uninstall${plain} - Uninstall │
@@ -3045,6 +3144,9 @@ if [[ $# > 0 ]]; then
"update-all-geofiles")
check_install 0 && update_all_geofiles 0 && restart 0
;;
"migrateDB")
migrate_db "$2" "$3"
;;
*) show_usage ;;
esac
else