mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-15 09:35:00 +03:00
Compare commits
6 Commits
0706b0b3a8
...
6ed6f57b5c
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ed6f57b5c | |||
| e409bc305d | |||
| 2b4e199a97 | |||
| 75bc6e8076 | |||
| eeb19b7240 | |||
| 5b9db13e55 |
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Сгенерировать случайный хеш",
|
||||
|
||||
Reference in New Issue
Block a user