mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-16 09:36:07 +00:00
Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fbad71d620 | |||
| a862680645 | |||
| 325550e57f | |||
| eb769e3f1f | |||
| 0061892d87 | |||
| eb669e1d83 | |||
| 48b60fec54 | |||
| 5f7c2424ef | |||
| 980c023287 | |||
| ebfaad1499 | |||
| 982ab276e8 | |||
| 8662c31296 | |||
| d2ba6c9fc2 | |||
| 58b88800b4 | |||
| ab418a47be | |||
| 46cac582ab | |||
| 588a524fa5 | |||
| 3f7e141d2e | |||
| 68a981c0c3 | |||
| a061a0ca87 | |||
| 61b59bb868 | |||
| 66b239da7d | |||
| 18349c36ac | |||
| 0f9099149e | |||
| 6b89613ad7 | |||
| 9b258becd0 | |||
| 06cd75abe0 | |||
| f2f11bc042 | |||
| 40da7fdb76 | |||
| 116ef900d5 | |||
| 89c27c5835 | |||
| 8c63f7cc81 | |||
| aaf17bcfdc | |||
| 0a1231d70c | |||
| 83de6e75e0 | |||
| babcf44891 | |||
| e8cf7242a0 | |||
| 82073c10c9 | |||
| fa4ac3100d | |||
| abab7cd000 | |||
| e4d4ab3a63 | |||
| 3eb214d022 | |||
| b6928f4939 | |||
| f7cae8d1cf | |||
| c3f0378e68 | |||
| 91e1e5eeff | |||
| 781458d878 | |||
| 0bbcd2a8f2 | |||
| ade3a8f870 | |||
| a77c365fe4 | |||
| 091dbc0c6e | |||
| e4ef8a54d4 | |||
| 7b266a9001 | |||
| 448e8c97c2 | |||
| 52442cb50c | |||
| 9ffbeb4938 | |||
| 97dd724424 |
@@ -26,7 +26,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v7
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
--health-retries 5
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v7
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v7
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v7
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v7
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
@@ -124,7 +124,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v7
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
@@ -139,7 +139,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v7
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
exclude: 'server\.go|xray\.go|inbound\.go|client_bulk\.go|inbound_traffic\.go|.*_postgres_test\.go'
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v7
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v7
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
check-latest: true
|
||||
@@ -211,7 +211,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v7
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
check-latest: true
|
||||
|
||||
@@ -152,8 +152,11 @@ export async function httpRequest(
|
||||
|
||||
if (res.status === 403 && !SAFE_METHODS.has(method.toUpperCase())) {
|
||||
csrfToken = null;
|
||||
const fresh = await ensureCsrfToken();
|
||||
if (fresh) res = await performFetch(method, url, data, options, fresh);
|
||||
const fresh = await fetchCsrfToken();
|
||||
if (fresh) {
|
||||
csrfToken = fresh;
|
||||
res = await performFetch(method, url, data, options, fresh);
|
||||
}
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
|
||||
@@ -190,3 +190,12 @@ export class WebSocketClient {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sharedClient: WebSocketClient | null = null;
|
||||
|
||||
export function getSharedWebSocketClient(): WebSocketClient {
|
||||
if (sharedClient) return sharedClient;
|
||||
const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || '';
|
||||
sharedClient = new WebSocketClient(basePath);
|
||||
return sharedClient;
|
||||
}
|
||||
|
||||
@@ -1,34 +1,19 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { WebSocketClient } from '@/api/websocket';
|
||||
import { getSharedWebSocketClient } from '@/api/websocket';
|
||||
import { keys } from '@/api/queryKeys';
|
||||
import { isRecentLocalInvalidate } from '@/api/invalidationTracker';
|
||||
|
||||
type Handler = (payload: unknown) => void;
|
||||
|
||||
interface SharedClient {
|
||||
connect(): void;
|
||||
on(event: string, fn: Handler): void;
|
||||
off(event: string, fn: Handler): void;
|
||||
}
|
||||
|
||||
let sharedClient: SharedClient | null = null;
|
||||
|
||||
function getSharedClient(): SharedClient {
|
||||
if (sharedClient) return sharedClient;
|
||||
const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || '';
|
||||
sharedClient = new WebSocketClient(basePath) as SharedClient;
|
||||
return sharedClient;
|
||||
}
|
||||
|
||||
let invalidateTimer: number | null = null;
|
||||
|
||||
export function useWebSocketBridge() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
const client = getSharedClient();
|
||||
const client = getSharedWebSocketClient();
|
||||
|
||||
const onInvalidate: Handler = (payload) => {
|
||||
const p = payload as { type?: string } | undefined;
|
||||
@@ -46,6 +31,7 @@ export function useWebSocketBridge() {
|
||||
};
|
||||
|
||||
const onOutbounds: Handler = (payload) => {
|
||||
if (!Array.isArray(payload)) return;
|
||||
queryClient.setQueryData(keys.xray.outboundsTraffic(), payload);
|
||||
};
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ body.light .config-block .ant-tag.ant-tag-filled.ant-tag-gold {
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
padding: 6px 8px;
|
||||
color: var(--ant-color-text);
|
||||
background: var(--ant-color-fill-tertiary);
|
||||
border-radius: 4px;
|
||||
user-select: all;
|
||||
|
||||
@@ -1,26 +1,11 @@
|
||||
import { useEffect } from 'react';
|
||||
import { WebSocketClient } from '@/api/websocket';
|
||||
import { getSharedWebSocketClient } from '@/api/websocket';
|
||||
|
||||
type Handler = (payload: unknown) => void;
|
||||
|
||||
interface SharedClient {
|
||||
connect(): void;
|
||||
on(event: string, fn: Handler): void;
|
||||
off(event: string, fn: Handler): void;
|
||||
}
|
||||
|
||||
let sharedClient: SharedClient | null = null;
|
||||
|
||||
function getSharedClient(): SharedClient {
|
||||
if (sharedClient) return sharedClient;
|
||||
const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || '';
|
||||
sharedClient = new WebSocketClient(basePath) as SharedClient;
|
||||
return sharedClient;
|
||||
}
|
||||
|
||||
export function useWebSocket(handlers: Record<string, Handler>) {
|
||||
useEffect(() => {
|
||||
const client = getSharedClient();
|
||||
const client = getSharedWebSocketClient();
|
||||
const entries = Object.entries(handlers);
|
||||
for (const [event, fn] of entries) client.on(event, fn);
|
||||
client.connect();
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { Form } from 'antd';
|
||||
|
||||
import SniffingFields from '@/lib/xray/forms/SniffingFields';
|
||||
import type { Sniffing } from '@/schemas/primitives/sniffing';
|
||||
import { SniffingSchema, type Sniffing } from '@/schemas/primitives/sniffing';
|
||||
|
||||
interface SniffingFieldProps {
|
||||
value?: Sniffing;
|
||||
@@ -12,12 +12,12 @@ interface SniffingFieldProps {
|
||||
|
||||
export default function SniffingField({ value, onChange, enableLabel }: SniffingFieldProps) {
|
||||
const [form] = Form.useForm();
|
||||
const [initial] = useState(() => value ?? ({} as Sniffing));
|
||||
const [initial] = useState(() => value ?? SniffingSchema.parse({}));
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
const lastEmitted = useRef(JSON.stringify(initial));
|
||||
|
||||
const sniffing = Form.useWatch('sniffing', form) as Sniffing | undefined;
|
||||
const sniffing = Form.useWatch('sniffing', { form, preserve: true }) as Sniffing | undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (sniffing === undefined) return;
|
||||
@@ -27,6 +27,14 @@ export default function SniffingField({ value, onChange, enableLabel }: Sniffing
|
||||
onChangeRef.current?.(sniffing);
|
||||
}, [sniffing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (value === undefined) return;
|
||||
const serialized = JSON.stringify(value);
|
||||
if (serialized === lastEmitted.current) return;
|
||||
lastEmitted.current = serialized;
|
||||
form.setFieldsValue({ sniffing: value });
|
||||
}, [value, form]);
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
|
||||
@@ -146,11 +146,18 @@ function bytesToGB(bytes: number): number {
|
||||
return Math.round((bytes / (1024 * 1024 * 1024)) * 100) / 100;
|
||||
}
|
||||
|
||||
function gbToBytes(gb: number): number {
|
||||
export function gbToBytes(gb: number): number {
|
||||
if (!gb || gb <= 0) return 0;
|
||||
return Math.round(gb * 1024 * 1024 * 1024);
|
||||
}
|
||||
|
||||
export function resolveTotalBytes(originalBytes: number | null | undefined, displayedGB: number): number {
|
||||
if (originalBytes != null && displayedGB === bytesToGB(originalBytes)) {
|
||||
return originalBytes;
|
||||
}
|
||||
return gbToBytes(displayedGB);
|
||||
}
|
||||
|
||||
export default function ClientFormModal({
|
||||
open,
|
||||
mode,
|
||||
@@ -491,6 +498,7 @@ export default function ClientFormModal({
|
||||
const expiryTime = values.delayedStart
|
||||
? -86400000 * (Number(values.delayedDays) || 0)
|
||||
: (values.expiryDate || 0);
|
||||
const totalBytes = resolveTotalBytes(client ? (client.totalGB ?? 0) : null, values.totalGB);
|
||||
const clientPayload: Record<string, unknown> = {
|
||||
email: values.email.trim(),
|
||||
subId: values.subId,
|
||||
@@ -499,7 +507,7 @@ export default function ClientFormModal({
|
||||
auth: values.auth,
|
||||
flow: showFlow ? (values.flow || '') : '',
|
||||
security: showSecurity ? (values.security || 'auto') : 'auto',
|
||||
totalGB: gbToBytes(values.totalGB),
|
||||
totalGB: totalBytes,
|
||||
expiryTime,
|
||||
reset: Number(values.reset) || 0,
|
||||
limitIp: Number(values.limitIp) || 0,
|
||||
|
||||
@@ -650,7 +650,7 @@ export default function NodeList({
|
||||
loading={loading}
|
||||
scroll={{ x: 'max-content' }}
|
||||
size="middle"
|
||||
rowKey="id"
|
||||
rowKey="key"
|
||||
rowSelection={dataSource.length > 1 ? {
|
||||
selectedRowKeys: selectedIds,
|
||||
onChange: (keys) => onSelectionChange(keys.filter((k) => typeof k === 'number') as number[]),
|
||||
|
||||
@@ -115,7 +115,8 @@ export default function BasicsTab({
|
||||
?.sockopt;
|
||||
const raw = sockopt?.happyEyeballs;
|
||||
if (raw == null || typeof raw !== 'object') return null;
|
||||
return HappyEyeballsSchema.parse(raw);
|
||||
const parsed = HappyEyeballsSchema.safeParse(raw);
|
||||
return parsed.success ? parsed.data : null;
|
||||
})();
|
||||
|
||||
const setDirectHappyEyeballs = useCallback(
|
||||
|
||||
@@ -63,8 +63,8 @@ export default function OutboundCardList({
|
||||
setShowEgressIp((prev) => ({ ...prev, [key]: visible }));
|
||||
};
|
||||
|
||||
const renderEgress = (index: number, rowKey: string) => {
|
||||
const result = testResult(outboundTestStates, index);
|
||||
const renderEgress = (testKey: number, rowKey: string) => {
|
||||
const result = testResult(outboundTestStates, testKey);
|
||||
const egress = result?.egress;
|
||||
const isEgressVisible = !!showEgressIp[rowKey];
|
||||
const flag = countryFlag(egress?.country);
|
||||
@@ -156,26 +156,26 @@ export default function OutboundCardList({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{renderEgress(index, String(record.key))}
|
||||
{renderEgress(record.key, String(record.key))}
|
||||
<div className="card-foot">
|
||||
<span className="traffic-up">↑ {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).up)}</span>
|
||||
<span className="traffic-sep" />
|
||||
<span className="traffic-down">↓ {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).down)}</span>
|
||||
<span className="card-test">
|
||||
{testResult(outboundTestStates, index) ? (
|
||||
<TestResultPopover result={testResult(outboundTestStates, index)!} />
|
||||
) : isTesting(outboundTestStates, index) ? (
|
||||
{testResult(outboundTestStates, record.key) ? (
|
||||
<TestResultPopover result={testResult(outboundTestStates, record.key)!} />
|
||||
) : isTesting(outboundTestStates, record.key) ? (
|
||||
<LoadingOutlined />
|
||||
) : null}
|
||||
<Button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
size="small"
|
||||
loading={isTesting(outboundTestStates, index)}
|
||||
disabled={isUntestable(record) || isTesting(outboundTestStates, index)}
|
||||
loading={isTesting(outboundTestStates, record.key)}
|
||||
disabled={isUntestable(record) || isTesting(outboundTestStates, record.key)}
|
||||
icon={<ThunderboltOutlined />}
|
||||
aria-label={t('check')}
|
||||
onClick={() => onTest(index, testMode)}
|
||||
onClick={() => onTest(record.key, testMode)}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Button,
|
||||
@@ -50,6 +50,7 @@ import type { XraySettingsValue, SetTemplate, OutboundTestMode, OutboundTestStat
|
||||
import './OutboundsTab.css';
|
||||
|
||||
import type { OutboundRow } from './outbounds-tab-types';
|
||||
import { originalOutboundIndex } from './outbounds-tab-helpers';
|
||||
import { useOutboundColumns } from './useOutboundColumns';
|
||||
import OutboundCardList from './OutboundCardList';
|
||||
import SubscriptionOutbounds from './SubscriptionOutbounds';
|
||||
@@ -150,6 +151,8 @@ export default function OutboundsTab({
|
||||
.filter((o) => !isBalancerLoopbackTag(o.tag || '')),
|
||||
[outbounds],
|
||||
);
|
||||
const rowsRef = useRef<OutboundRow[]>([]);
|
||||
rowsRef.current = rows;
|
||||
|
||||
const dialerProxyTags = useMemo(() => {
|
||||
const tags = new Set<string>();
|
||||
@@ -188,11 +191,12 @@ export default function OutboundsTab({
|
||||
loadSubs();
|
||||
}
|
||||
function openEdit(idx: number) {
|
||||
setEditingOutbound((templateSettings?.outbounds || [])[idx] as Record<string, unknown>);
|
||||
setEditingIndex(idx);
|
||||
const target = originalOutboundIndex(rowsRef.current, idx);
|
||||
setEditingOutbound((templateSettings?.outbounds || [])[target] as Record<string, unknown>);
|
||||
setEditingIndex(target);
|
||||
setExistingTags(
|
||||
(templateSettings?.outbounds || [])
|
||||
.filter((_, i) => i !== idx)
|
||||
.filter((_, i) => i !== target)
|
||||
.map((o) => o?.tag)
|
||||
.filter((tg): tg is string => !!tg),
|
||||
);
|
||||
@@ -217,8 +221,9 @@ export default function OutboundsTab({
|
||||
}
|
||||
|
||||
function confirmDelete(idx: number) {
|
||||
const target = originalOutboundIndex(rowsRef.current, idx);
|
||||
const impact = templateSettings
|
||||
? planOutboundDeletion(templateSettings, idx)
|
||||
? planOutboundDeletion(templateSettings, target)
|
||||
: { rules: [], balancers: [], observatory: false, burst: false };
|
||||
modal.confirm({
|
||||
title: `${t('delete')} ${t('pages.xray.Outbounds')} #${idx + 1}?`,
|
||||
@@ -226,27 +231,33 @@ export default function OutboundsTab({
|
||||
okText: t('delete'),
|
||||
okType: 'danger',
|
||||
cancelText: t('cancel'),
|
||||
onOk: () => mutate((tt) => applyOutboundDeletion(tt, idx)),
|
||||
onOk: () => mutate((tt) => applyOutboundDeletion(tt, target)),
|
||||
});
|
||||
}
|
||||
function setFirst(idx: number) {
|
||||
const target = originalOutboundIndex(rowsRef.current, idx);
|
||||
mutate((tt) => {
|
||||
if (!tt.outbounds) return;
|
||||
const [moved] = tt.outbounds.splice(idx, 1);
|
||||
const [moved] = tt.outbounds.splice(target, 1);
|
||||
tt.outbounds.unshift(moved);
|
||||
});
|
||||
}
|
||||
function moveUp(idx: number) {
|
||||
if (idx <= 0) return;
|
||||
const target = originalOutboundIndex(rowsRef.current, idx);
|
||||
const prev = originalOutboundIndex(rowsRef.current, idx - 1);
|
||||
mutate((tt) => {
|
||||
if (!tt.outbounds) return;
|
||||
[tt.outbounds[idx - 1], tt.outbounds[idx]] = [tt.outbounds[idx], tt.outbounds[idx - 1]];
|
||||
[tt.outbounds[prev], tt.outbounds[target]] = [tt.outbounds[target], tt.outbounds[prev]];
|
||||
});
|
||||
}
|
||||
function moveDown(idx: number) {
|
||||
if (idx >= rowsRef.current.length - 1) return;
|
||||
const target = originalOutboundIndex(rowsRef.current, idx);
|
||||
const next = originalOutboundIndex(rowsRef.current, idx + 1);
|
||||
mutate((tt) => {
|
||||
if (!tt.outbounds || idx >= tt.outbounds.length - 1) return;
|
||||
[tt.outbounds[idx + 1], tt.outbounds[idx]] = [tt.outbounds[idx], tt.outbounds[idx + 1]];
|
||||
if (!tt.outbounds) return;
|
||||
[tt.outbounds[next], tt.outbounds[target]] = [tt.outbounds[target], tt.outbounds[next]];
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,19 @@ import type { OutboundTestMode, OutboundTestState, OutboundTrafficRow } from '@/
|
||||
|
||||
import type { OutboundRow } from './outbounds-tab-types';
|
||||
|
||||
/**
|
||||
* Translate a table row's positional index into that outbound's index in the
|
||||
* full, unfiltered outbounds array. The table hides balancer-loopback outbounds
|
||||
* but keeps each visible row's original index in `key`, so any handler that
|
||||
* mutates the outbounds array (or probes an outbound by index) must map the
|
||||
* positional index back through `key` or it operates on the wrong outbound once
|
||||
* a hidden loopback precedes it.
|
||||
*/
|
||||
export function originalOutboundIndex(rows: OutboundRow[], positionalIndex: number): number {
|
||||
const row = rows[positionalIndex];
|
||||
return row ? row.key : positionalIndex;
|
||||
}
|
||||
|
||||
export function outboundAddresses(o: OutboundRow): string[] {
|
||||
const settings = o.settings as Record<string, unknown> | undefined;
|
||||
switch (o.protocol) {
|
||||
|
||||
@@ -158,8 +158,8 @@ export function useOutboundColumns({
|
||||
key: 'egress',
|
||||
align: 'left',
|
||||
width: 210,
|
||||
render: (_v, _record, index) => {
|
||||
const egress = testResult(outboundTestStates, index)?.egress;
|
||||
render: (_v, record) => {
|
||||
const egress = testResult(outboundTestStates, record.key)?.egress;
|
||||
const addresses = [
|
||||
egress?.ipv4 ? { label: 'v4', value: egress.ipv4 } : null,
|
||||
egress?.ipv6 ? { label: 'v6', value: egress.ipv6 } : null,
|
||||
@@ -190,8 +190,8 @@ export function useOutboundColumns({
|
||||
key: 'egressCountry',
|
||||
align: 'left',
|
||||
width: 160,
|
||||
render: (_v, _record, index) => {
|
||||
const egress = testResult(outboundTestStates, index)?.egress;
|
||||
render: (_v, record) => {
|
||||
const egress = testResult(outboundTestStates, record.key)?.egress;
|
||||
if (!egress?.country) {
|
||||
return (
|
||||
<Tooltip title={t('pages.xray.outbound.egressHint')}>
|
||||
@@ -229,9 +229,9 @@ export function useOutboundColumns({
|
||||
key: 'testResult',
|
||||
align: 'left',
|
||||
width: 140,
|
||||
render: (_v, _record, index) => {
|
||||
const r = testResult(outboundTestStates, index);
|
||||
if (!r) return isTesting(outboundTestStates, index) ? <LoadingOutlined /> : <span className="empty">—</span>;
|
||||
render: (_v, record) => {
|
||||
const r = testResult(outboundTestStates, record.key);
|
||||
if (!r) return isTesting(outboundTestStates, record.key) ? <LoadingOutlined /> : <span className="empty">—</span>;
|
||||
return <TestResultPopover result={r} />;
|
||||
},
|
||||
},
|
||||
@@ -240,16 +240,16 @@ export function useOutboundColumns({
|
||||
key: 'test',
|
||||
align: 'center',
|
||||
width: 80,
|
||||
render: (_v, record, index) => (
|
||||
render: (_v, record) => (
|
||||
<Tooltip title={`${t('check')} (${testModeLabel(effectiveTestMode(record, testMode), t)})`}>
|
||||
<Button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
loading={isTesting(outboundTestStates, index)}
|
||||
disabled={isUntestable(record) || isTesting(outboundTestStates, index)}
|
||||
loading={isTesting(outboundTestStates, record.key)}
|
||||
disabled={isUntestable(record) || isTesting(outboundTestStates, record.key)}
|
||||
icon={<ThunderboltOutlined />}
|
||||
aria-label={t('check')}
|
||||
onClick={() => onTest(index, testMode)}
|
||||
onClick={() => onTest(record.key, testMode)}
|
||||
/>
|
||||
</Tooltip>
|
||||
),
|
||||
|
||||
@@ -21,7 +21,7 @@ import RuleFormModal from './RuleFormModal';
|
||||
import type { RoutingRule } from './RuleFormModal';
|
||||
import RuleCardList from './RuleCardList';
|
||||
import { useRoutingColumns } from './useRoutingColumns';
|
||||
import { arrJoin } from './helpers';
|
||||
import { arrJoin, originalRuleIndex } from './helpers';
|
||||
import type { RuleRow } from './types';
|
||||
import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
|
||||
import type { RuleObject } from '@/schemas/routing';
|
||||
@@ -64,6 +64,7 @@ export default function RoutingTab({
|
||||
);
|
||||
const rulesRef = useRef(rules);
|
||||
rulesRef.current = rules;
|
||||
const rowsRef = useRef<RuleRow[]>([]);
|
||||
|
||||
const rows: RuleRow[] = useMemo(
|
||||
() =>
|
||||
@@ -94,6 +95,7 @@ export default function RoutingTab({
|
||||
}),
|
||||
[rules],
|
||||
);
|
||||
rowsRef.current = rows;
|
||||
|
||||
const mutate = useCallback(
|
||||
(mutator: (next: XraySettingsValue) => void) => {
|
||||
@@ -193,8 +195,9 @@ export default function RoutingTab({
|
||||
setRuleModalOpen(true);
|
||||
}
|
||||
function openEdit(idx: number) {
|
||||
setEditingRule(rulesRef.current[idx]);
|
||||
setEditingIndex(idx);
|
||||
const target = originalRuleIndex(rowsRef.current, idx);
|
||||
setEditingRule(rulesRef.current[target]);
|
||||
setEditingIndex(target);
|
||||
setRuleModalOpen(true);
|
||||
}
|
||||
function onRuleConfirm(rule: Record<string, unknown>) {
|
||||
@@ -213,37 +216,44 @@ export default function RoutingTab({
|
||||
}
|
||||
|
||||
function confirmDelete(idx: number) {
|
||||
const target = originalRuleIndex(rowsRef.current, idx);
|
||||
modal.confirm({
|
||||
title: `${t('delete')} ${t('pages.xray.Routings')} #${idx + 1}?`,
|
||||
okText: t('delete'),
|
||||
okType: 'danger',
|
||||
cancelText: t('cancel'),
|
||||
onOk: () => mutate((tt) => {
|
||||
tt.routing?.rules?.splice(idx, 1);
|
||||
tt.routing?.rules?.splice(target, 1);
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function moveUp(idx: number) {
|
||||
if (idx <= 0) return;
|
||||
const target = originalRuleIndex(rowsRef.current, idx);
|
||||
const prev = originalRuleIndex(rowsRef.current, idx - 1);
|
||||
mutate((tt) => {
|
||||
const list = tt.routing?.rules;
|
||||
if (!list) return;
|
||||
[list[idx - 1], list[idx]] = [list[idx], list[idx - 1]];
|
||||
if (!list || !list[target] || !list[prev]) return;
|
||||
[list[prev], list[target]] = [list[target], list[prev]];
|
||||
});
|
||||
}
|
||||
function moveDown(idx: number) {
|
||||
if (idx >= rowsRef.current.length - 1) return;
|
||||
const target = originalRuleIndex(rowsRef.current, idx);
|
||||
const next = originalRuleIndex(rowsRef.current, idx + 1);
|
||||
mutate((tt) => {
|
||||
const list = tt.routing?.rules;
|
||||
if (!list || idx >= list.length - 1) return;
|
||||
[list[idx + 1], list[idx]] = [list[idx], list[idx + 1]];
|
||||
if (!list || !list[target] || !list[next]) return;
|
||||
[list[next], list[target]] = [list[target], list[next]];
|
||||
});
|
||||
}
|
||||
function toggleRule(idx: number, enabled: boolean) {
|
||||
const target = originalRuleIndex(rowsRef.current, idx);
|
||||
mutate((tt) => {
|
||||
const list = tt.routing?.rules;
|
||||
if (!list || !list[idx]) return;
|
||||
list[idx].enabled = enabled;
|
||||
if (!list || !list[target]) return;
|
||||
list[target].enabled = enabled;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -282,11 +292,13 @@ export default function RoutingTab({
|
||||
setDraggedIndex(null);
|
||||
setDropTargetIndex(null);
|
||||
if (!moved || from == null || to == null || from === to) return;
|
||||
const fromOrig = originalRuleIndex(rowsRef.current, from);
|
||||
const toOrig = originalRuleIndex(rowsRef.current, to);
|
||||
mutate((tt) => {
|
||||
const list = tt.routing?.rules;
|
||||
if (!list) return;
|
||||
const [movedItem] = list.splice(from, 1);
|
||||
list.splice(to, 0, movedItem);
|
||||
const [movedItem] = list.splice(fromOrig, 1);
|
||||
list.splice(toOrig, 0, movedItem);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -126,7 +126,13 @@ export default function RuleFormModal({
|
||||
outboundTag: v.outboundTag === '' ? undefined : v.outboundTag,
|
||||
balancerTag: v.balancerTag === '' ? undefined : v.balancerTag,
|
||||
};
|
||||
const managedKeys = new Set(Object.keys(built));
|
||||
const out: Record<string, unknown> = {};
|
||||
if (rule) {
|
||||
for (const [key, value] of Object.entries(rule)) {
|
||||
if (!managedKeys.has(key) && value !== undefined) out[key] = value;
|
||||
}
|
||||
}
|
||||
for (const [k, v] of Object.entries(built)) {
|
||||
if (v == null) continue;
|
||||
if (Array.isArray(v) && v.length === 0) continue;
|
||||
|
||||
@@ -6,6 +6,18 @@ export function arrJoin(v: unknown): string | undefined {
|
||||
return String(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a table row's positional index into that rule's index in the full,
|
||||
* unfiltered routing.rules array. The table hides balancer-loopback rules but
|
||||
* keeps each visible row's original index in `key`, so any handler that mutates
|
||||
* routing.rules must map the positional index back through `key` or it operates
|
||||
* on the wrong rule once a hidden loopback precedes it.
|
||||
*/
|
||||
export function originalRuleIndex(rows: RuleRow[], positionalIndex: number): number {
|
||||
const row = rows[positionalIndex];
|
||||
return row ? row.key : positionalIndex;
|
||||
}
|
||||
|
||||
export function csv(value?: string): string[] {
|
||||
if (!value) return [];
|
||||
return String(value).split(',').map((s) => s.trim()).filter(Boolean);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
import BasicsTab from '@/pages/xray/basics/BasicsTab';
|
||||
import type { XraySettingsValue } from '@/hooks/useXraySetting';
|
||||
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
function settingsWithMalformedHappyEyeballs(): XraySettingsValue {
|
||||
return {
|
||||
outbounds: [
|
||||
{
|
||||
protocol: 'freedom',
|
||||
tag: 'direct',
|
||||
streamSettings: { sockopt: { happyEyeballs: { tryDelayMs: 'fast' } } },
|
||||
},
|
||||
],
|
||||
} as unknown as XraySettingsValue;
|
||||
}
|
||||
|
||||
describe('BasicsTab malformed happyEyeballs', () => {
|
||||
it('renders instead of white-screening on a wrong-typed happyEyeballs value', () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
expect(() =>
|
||||
renderWithProviders(
|
||||
<BasicsTab
|
||||
templateSettings={settingsWithMalformedHappyEyeballs()}
|
||||
setTemplateSettings={vi.fn()}
|
||||
outboundTestUrl=""
|
||||
onChangeOutboundTestUrl={vi.fn()}
|
||||
onResetDefault={vi.fn()}
|
||||
/>,
|
||||
),
|
||||
).not.toThrow();
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { resolveTotalBytes, gbToBytes } from '@/pages/clients/ClientFormModal';
|
||||
|
||||
describe('resolveTotalBytes', () => {
|
||||
it('preserves the original byte total on a no-op save of a non-GB-aligned quota', () => {
|
||||
const original = 11_505_016_832;
|
||||
const displayedGB = Math.round((original / 1024 ** 3) * 100) / 100;
|
||||
expect(resolveTotalBytes(original, displayedGB)).toBe(original);
|
||||
});
|
||||
|
||||
it('re-derives bytes from GB when the user changed the quota', () => {
|
||||
const original = 11_505_016_832;
|
||||
expect(resolveTotalBytes(original, 20)).toBe(gbToBytes(20));
|
||||
});
|
||||
|
||||
it('uses the GB value directly when there is no original (add mode)', () => {
|
||||
expect(resolveTotalBytes(null, 5)).toBe(gbToBytes(5));
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,35 @@ describe('httpRequest against the MSW-mocked network', () => {
|
||||
expect(res.data).toEqual({ success: true, obj: 'saved' });
|
||||
});
|
||||
|
||||
it('on a 403 refetches a fresh token from the server even when a stale meta tag is present', async () => {
|
||||
const STALE = 'stale-meta-token';
|
||||
const FRESH = 'fresh-server-token';
|
||||
vi.stubGlobal('document', {
|
||||
querySelector: (selector: string) =>
|
||||
selector === 'meta[name="csrf-token"]' ? { getAttribute: () => STALE } : null,
|
||||
});
|
||||
setupHttp();
|
||||
|
||||
let posts = 0;
|
||||
const sentTokens: (string | null)[] = [];
|
||||
server.use(
|
||||
http.get(`${ORIGIN}/csrf-token`, () => HttpResponse.json({ success: true, obj: FRESH })),
|
||||
http.post(`${ORIGIN}/panel/api/test`, ({ request }) => {
|
||||
posts += 1;
|
||||
const token = request.headers.get('X-CSRF-Token');
|
||||
sentTokens.push(token);
|
||||
if (token !== FRESH) return new HttpResponse(null, { status: 403 });
|
||||
return HttpResponse.json({ success: true, obj: 'saved' });
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await httpRequest('POST', '/panel/api/test', { hello: 'world' });
|
||||
|
||||
expect(sentTokens).toEqual([STALE, FRESH]);
|
||||
expect(posts).toBe(2);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('resolves a safe GET without requesting a CSRF token', async () => {
|
||||
let csrfHits = 0;
|
||||
server.use(
|
||||
|
||||
@@ -73,8 +73,8 @@ describe('HttpUtil', () => {
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps a thrown HttpError to a failure Msg via response.data.message', async () => {
|
||||
mockRequest.mockRejectedValue(new HttpError(400, 'Bad Request', { message: 'bad input' }));
|
||||
it('surfaces the backend error text from a thrown HttpError body (msg field)', async () => {
|
||||
mockRequest.mockRejectedValue(new HttpError(400, 'Bad Request', { success: false, msg: 'bad input' }));
|
||||
|
||||
const msg = await HttpUtil.post('/x', undefined, { silent: true });
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
|
||||
import NodeList from '@/pages/nodes/NodeList';
|
||||
import type { NodeRecord } from '@/schemas/node';
|
||||
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
function sampleNodes(): NodeRecord[] {
|
||||
return [
|
||||
{ id: 1, name: 'parent', guid: 'p1', transitive: false, enable: true, status: 'online' },
|
||||
{ id: 0, name: 'child-a', guid: 'ca', parentGuid: 'p1', transitive: true },
|
||||
{ id: 0, name: 'child-b', guid: 'cb', parentGuid: 'p1', transitive: true },
|
||||
];
|
||||
}
|
||||
|
||||
describe('NodeList desktop table row keys', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('gives transitive sub-node rows distinct keys instead of colliding on id 0', () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
renderWithProviders(
|
||||
<NodeList
|
||||
nodes={sampleNodes()}
|
||||
isMobile={false}
|
||||
selectedIds={[]}
|
||||
onSelectionChange={noop}
|
||||
onAdd={noop}
|
||||
onMtls={noop}
|
||||
onEdit={noop}
|
||||
onDelete={noop}
|
||||
onProbe={noop}
|
||||
onToggleEnable={noop}
|
||||
onUpdateNode={noop}
|
||||
onUpdateSelected={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const duplicateKeyWarning = errorSpy.mock.calls.some((call) =>
|
||||
call.some((arg) => typeof arg === 'string' && arg.includes('same key')),
|
||||
);
|
||||
expect(duplicateKeyWarning).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { act } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { act, fireEvent } from '@testing-library/react';
|
||||
|
||||
import OutboundFormModal from '@/pages/xray/outbounds/OutboundFormModal';
|
||||
import {
|
||||
@@ -54,4 +54,41 @@ describe('OutboundFormModal', () => {
|
||||
expect(labelsByProto.wireguard).not.toContain('Encryption');
|
||||
}
|
||||
}, 30000); // iterates every protocol, re-rendering a heavy modal each time — slow on CI runners
|
||||
|
||||
it('saves a vless reverse outbound while reverse sniffing stays disabled', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
renderWithProviders(
|
||||
<OutboundFormModal
|
||||
open
|
||||
outbound={{
|
||||
protocol: 'vless',
|
||||
tag: 'reverse-out',
|
||||
settings: {
|
||||
vnext: [{
|
||||
address: 'example.com',
|
||||
port: 443,
|
||||
users: [{ id: 'c9f0c2d0-0000-4000-8000-000000000000', encryption: 'none' }],
|
||||
}],
|
||||
reverse: { tag: 'r1', sniffing: {} },
|
||||
},
|
||||
streamSettings: { network: 'tcp', security: 'none' },
|
||||
}}
|
||||
existingTags={[]}
|
||||
onClose={() => {}}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 0)); });
|
||||
|
||||
const ok = document.querySelector('.ant-modal-footer .ant-btn-primary') as HTMLElement;
|
||||
expect(ok).toBeTruthy();
|
||||
await act(async () => { fireEvent.click(ok); });
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 0)); });
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
const payload = onConfirm.mock.calls[0][0] as {
|
||||
settings: { reverse?: { tag?: string } };
|
||||
};
|
||||
expect(payload.settings.reverse?.tag).toBe('r1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -250,6 +250,17 @@ describe('parseShadowsocksLink', () => {
|
||||
expect(settings.servers[0].method).toBe('aes-256-gcm');
|
||||
expect(settings.servers[0].password).toBe('legacypw');
|
||||
});
|
||||
|
||||
it('decodes URL-safe base64 userinfo (as the emitter writes it)', () => {
|
||||
const method = 'aes-256-gcm';
|
||||
const password = '>>>';
|
||||
const userinfo = Base64.encode(`${method}:${password}`, true);
|
||||
expect(userinfo).toMatch(/[-_]/);
|
||||
const out = parseShadowsocksLink(`ss://${userinfo}@1.2.3.4:8388#urlsafe`);
|
||||
const settings = out?.settings as { servers: Array<{ method: string; password: string }> };
|
||||
expect(settings.servers[0].method).toBe(method);
|
||||
expect(settings.servers[0].password).toBe(password);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseHysteria2Link', () => {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import OutboundsTab from '@/pages/xray/outbounds/OutboundsTab';
|
||||
import type { XraySettingsValue } from '@/hooks/useXraySetting';
|
||||
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
function settingsWithHiddenLoopback(): XraySettingsValue {
|
||||
return {
|
||||
outbounds: [
|
||||
{ tag: 'proxy-a', protocol: 'vmess' },
|
||||
{ tag: '_bl_bal1', protocol: 'loopback' },
|
||||
{ tag: 'proxy-b', protocol: 'vmess' },
|
||||
],
|
||||
} as unknown as XraySettingsValue;
|
||||
}
|
||||
|
||||
describe('OutboundsTab hidden-loopback index mapping', () => {
|
||||
it('probes the outbound at its real array index, not the positional row index', () => {
|
||||
const onTest = vi.fn();
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
renderWithProviders(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<OutboundsTab
|
||||
templateSettings={settingsWithHiddenLoopback()}
|
||||
setTemplateSettings={vi.fn()}
|
||||
outboundsTraffic={[]}
|
||||
outboundTestStates={{}}
|
||||
subscriptionTestStates={{}}
|
||||
testingAll={false}
|
||||
inboundTags={[]}
|
||||
isMobile={false}
|
||||
onResetTraffic={vi.fn()}
|
||||
onTest={onTest}
|
||||
onTestSubscription={vi.fn()}
|
||||
onTestAll={vi.fn()}
|
||||
onShowWarp={vi.fn()}
|
||||
onShowNord={vi.fn()}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const tbody = document.querySelector('.ant-table-tbody');
|
||||
const tableRows = tbody?.querySelectorAll('tr.ant-table-row') ?? [];
|
||||
expect(tableRows.length).toBe(2);
|
||||
|
||||
const checkButton = tableRows[1].querySelector('button[aria-label="Check"]') as HTMLButtonElement;
|
||||
fireEvent.click(checkButton);
|
||||
|
||||
expect(onTest).toHaveBeenCalledTimes(1);
|
||||
expect(onTest.mock.calls[0][0]).toBe(2);
|
||||
});
|
||||
|
||||
it('probes the real array index from the mobile card list as well', () => {
|
||||
const onTest = vi.fn();
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
renderWithProviders(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<OutboundsTab
|
||||
templateSettings={settingsWithHiddenLoopback()}
|
||||
setTemplateSettings={vi.fn()}
|
||||
outboundsTraffic={[]}
|
||||
outboundTestStates={{}}
|
||||
subscriptionTestStates={{}}
|
||||
testingAll={false}
|
||||
inboundTags={[]}
|
||||
isMobile
|
||||
onResetTraffic={vi.fn()}
|
||||
onTest={onTest}
|
||||
onTestSubscription={vi.fn()}
|
||||
onTestAll={vi.fn()}
|
||||
onShowWarp={vi.fn()}
|
||||
onShowNord={vi.fn()}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const cards = document.querySelectorAll('.outbound-card');
|
||||
expect(cards.length).toBe(2);
|
||||
|
||||
const checkButton = cards[1].querySelector('button[aria-label="Check"]') as HTMLButtonElement;
|
||||
fireEvent.click(checkButton);
|
||||
|
||||
expect(onTest).toHaveBeenCalledTimes(1);
|
||||
expect(onTest.mock.calls[0][0]).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import RoutingTab from '@/pages/xray/routing/RoutingTab';
|
||||
import type { XraySettingsValue } from '@/hooks/useXraySetting';
|
||||
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
function settingsWithHiddenLoopback(): XraySettingsValue {
|
||||
return {
|
||||
routing: {
|
||||
rules: [
|
||||
{ type: 'field', outboundTag: 'a', enabled: true },
|
||||
{ type: 'field', inboundTag: ['_bl_bal1'], outboundTag: 'b', enabled: true },
|
||||
{ type: 'field', outboundTag: 'c', enabled: true },
|
||||
],
|
||||
},
|
||||
} as unknown as XraySettingsValue;
|
||||
}
|
||||
|
||||
describe('RoutingTab hidden-loopback index mapping', () => {
|
||||
it('toggles the visible rule, not the hidden loopback rule that precedes it', () => {
|
||||
const setTemplateSettings = vi.fn();
|
||||
const initial = settingsWithHiddenLoopback();
|
||||
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
renderWithProviders(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RoutingTab
|
||||
templateSettings={initial}
|
||||
setTemplateSettings={setTemplateSettings}
|
||||
inboundTags={[]}
|
||||
clientReverseTags={[]}
|
||||
isMobile={false}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: /Routing Rules/ }));
|
||||
|
||||
const switches = document.querySelectorAll('.routing-table .ant-switch');
|
||||
expect(switches.length).toBe(2);
|
||||
|
||||
fireEvent.click(switches[1]);
|
||||
|
||||
expect(setTemplateSettings).toHaveBeenCalledTimes(1);
|
||||
const updater = setTemplateSettings.mock.calls[0][0] as (prev: XraySettingsValue) => XraySettingsValue;
|
||||
const next = updater(initial);
|
||||
const rules = (next.routing as { rules: Array<{ enabled?: boolean }> }).rules;
|
||||
|
||||
expect(rules[2].enabled).toBe(false);
|
||||
expect(rules[1].enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import RuleFormModal from '@/pages/xray/routing/RuleFormModal';
|
||||
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
describe('RuleFormModal edit preserves unsurfaced fields', () => {
|
||||
it('keeps a field the form does not surface (ruleTag) when saving an edit', () => {
|
||||
const onConfirm = vi.fn();
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
renderWithProviders(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RuleFormModal
|
||||
open
|
||||
rule={{ type: 'field', outboundTag: 'block', ruleTag: 'my-tag', enabled: true }}
|
||||
inboundTags={[]}
|
||||
outboundTags={['block']}
|
||||
balancerTags={[]}
|
||||
onClose={vi.fn()}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
expect(onConfirm.mock.calls[0][0]).toMatchObject({ ruleTag: 'my-tag' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
import SniffingField from '@/lib/xray/forms/fields/SniffingField';
|
||||
import { SniffingSchema } from '@/schemas/primitives/sniffing';
|
||||
|
||||
describe('SniffingField external re-sync', () => {
|
||||
it('reflects an external value change (e.g. an advanced JSON edit) in the friendly form', () => {
|
||||
const disabled = SniffingSchema.parse({ enabled: false });
|
||||
const enabled = SniffingSchema.parse({ enabled: true });
|
||||
const onChange = vi.fn();
|
||||
|
||||
const { rerender, getByRole } = render(
|
||||
<SniffingField value={disabled} onChange={onChange} enableLabel="Enable" />,
|
||||
);
|
||||
expect(getByRole('switch').getAttribute('aria-checked')).toBe('false');
|
||||
|
||||
rerender(<SniffingField value={enabled} onChange={onChange} enableLabel="Enable" />);
|
||||
expect(getByRole('switch').getAttribute('aria-checked')).toBe('true');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { getSharedWebSocketClient } from '@/api/websocket';
|
||||
import { useWebSocketBridge } from '@/api/websocketBridge';
|
||||
import { keys } from '@/api/queryKeys';
|
||||
|
||||
type ListenerMap = { listeners: Map<string, Set<(payload: unknown) => void>> };
|
||||
|
||||
describe('websocket bridge outbounds handler', () => {
|
||||
let originalWS: typeof WebSocket;
|
||||
|
||||
beforeEach(() => {
|
||||
originalWS = globalThis.WebSocket;
|
||||
class FakeWebSocket {
|
||||
static readonly CONNECTING = 0;
|
||||
static readonly OPEN = 1;
|
||||
static readonly CLOSING = 2;
|
||||
static readonly CLOSED = 3;
|
||||
readyState = FakeWebSocket.CONNECTING;
|
||||
addEventListener() {}
|
||||
removeEventListener() {}
|
||||
close() {}
|
||||
send() {}
|
||||
}
|
||||
globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.WebSocket = originalWS;
|
||||
});
|
||||
|
||||
it('ignores a non-array outbounds push instead of poisoning the cache', () => {
|
||||
const queryClient = new QueryClient();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
|
||||
renderHook(() => useWebSocketBridge(), { wrapper });
|
||||
|
||||
const client = getSharedWebSocketClient() as unknown as ListenerMap;
|
||||
const handlers = client.listeners.get('outbounds');
|
||||
expect(handlers && handlers.size).toBeGreaterThan(0);
|
||||
|
||||
for (const handler of handlers ?? []) handler({ not: 'an array' });
|
||||
|
||||
expect(queryClient.getQueryData(keys.xray.outboundsTraffic())).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { useWebSocket } from '@/hooks/useWebSocket';
|
||||
import { useWebSocketBridge } from '@/api/websocketBridge';
|
||||
|
||||
describe('shared WebSocket connection', () => {
|
||||
let socketCount = 0;
|
||||
let originalWS: typeof WebSocket;
|
||||
|
||||
beforeEach(() => {
|
||||
socketCount = 0;
|
||||
originalWS = globalThis.WebSocket;
|
||||
class FakeWebSocket {
|
||||
static readonly CONNECTING = 0;
|
||||
static readonly OPEN = 1;
|
||||
static readonly CLOSING = 2;
|
||||
static readonly CLOSED = 3;
|
||||
readyState = FakeWebSocket.CONNECTING;
|
||||
constructor() {
|
||||
socketCount += 1;
|
||||
}
|
||||
addEventListener() {}
|
||||
removeEventListener() {}
|
||||
close() {}
|
||||
send() {}
|
||||
}
|
||||
globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.WebSocket = originalWS;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('opens a single socket when the bridge and a page hook are both mounted', () => {
|
||||
const queryClient = new QueryClient();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
|
||||
renderHook(() => useWebSocketBridge(), { wrapper });
|
||||
renderHook(() => useWebSocket({ traffic: () => {} }));
|
||||
|
||||
expect(socketCount).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -76,8 +76,9 @@ export class HttpUtil {
|
||||
return msg;
|
||||
} catch (error) {
|
||||
console.error('GET request failed:', error);
|
||||
const err = error as { response?: { data?: { message?: string } }; message?: string };
|
||||
const errorMsg = new Msg<T>(false, err.response?.data?.message || err.message || 'Request failed');
|
||||
const err = error as { response?: { data?: { msg?: string; message?: string } }; message?: string };
|
||||
const data = err.response?.data;
|
||||
const errorMsg = new Msg<T>(false, data?.msg || data?.message || err.message || 'Request failed');
|
||||
if (!silent) this._handleMsg(errorMsg);
|
||||
return errorMsg;
|
||||
}
|
||||
@@ -92,8 +93,9 @@ export class HttpUtil {
|
||||
return msg;
|
||||
} catch (error) {
|
||||
console.error('POST request failed:', error);
|
||||
const err = error as { response?: { data?: { message?: string } }; message?: string };
|
||||
const errorMsg = new Msg<T>(false, err.response?.data?.message || err.message || 'Request failed');
|
||||
const err = error as { response?: { data?: { msg?: string; message?: string } }; message?: string };
|
||||
const data = err.response?.data;
|
||||
const errorMsg = new Msg<T>(false, data?.msg || data?.message || err.message || 'Request failed');
|
||||
if (!silent) this._handleMsg(errorMsg);
|
||||
return errorMsg;
|
||||
}
|
||||
@@ -641,8 +643,10 @@ export class Base64 {
|
||||
}
|
||||
|
||||
static decode(content: string = ''): string {
|
||||
const normalized = content.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=');
|
||||
return new TextDecoder().decode(
|
||||
Uint8Array.from(window.atob(content), (c) => c.charCodeAt(0)),
|
||||
Uint8Array.from(window.atob(padded), (c) => c.charCodeAt(0)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1057,7 +1057,7 @@ func runSeeders(isUsersEmpty bool) error {
|
||||
}
|
||||
|
||||
if empty && isUsersEmpty {
|
||||
seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "ApiTokensHash", "LegacyProxySettingsCleanup", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted"}
|
||||
seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "ApiTokensHash", "LegacyProxySettingsCleanup", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "ResetIpLimitNoFail2ban"}
|
||||
for _, name := range seeders {
|
||||
if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func TestFreshInstallFastPathMarksResetIpLimitSeeder(t *testing.T) {
|
||||
if err := InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = CloseDB() })
|
||||
|
||||
if err := db.Where("1 = 1").Delete(&model.HistoryOfSeeders{}).Error; err != nil {
|
||||
t.Fatalf("reset seeder history: %v", err)
|
||||
}
|
||||
if err := db.Where("1 = 1").Delete(&model.User{}).Error; err != nil {
|
||||
t.Fatalf("reset users: %v", err)
|
||||
}
|
||||
|
||||
if err := runSeeders(true); err != nil {
|
||||
t.Fatalf("runSeeders: %v", err)
|
||||
}
|
||||
|
||||
var cnt int64
|
||||
if err := db.Model(&model.HistoryOfSeeders{}).
|
||||
Where("seeder_name = ?", "ResetIpLimitNoFail2ban").
|
||||
Count(&cnt).Error; err != nil {
|
||||
t.Fatalf("count seeder history: %v", err)
|
||||
}
|
||||
if cnt != 1 {
|
||||
t.Fatal("fresh-install fast path must mark ResetIpLimitNoFail2ban done so it cannot wipe admin-set IP limits on the next boot")
|
||||
}
|
||||
}
|
||||
+75
-23
@@ -10,21 +10,31 @@ import (
|
||||
// DefaultBufferSize is the number of events the bus can hold before Publish starts dropping.
|
||||
const DefaultBufferSize = 256
|
||||
|
||||
// subscriber pairs an ID with its event handler.
|
||||
// subscriberQueueSize bounds how many undelivered events a single subscriber may
|
||||
// hold before the newest are dropped. Each subscriber drains its own queue on a
|
||||
// dedicated worker goroutine, so a slow subscriber can neither stall delivery to
|
||||
// the others nor make the bus spawn an unbounded number of goroutines.
|
||||
const subscriberQueueSize = 64
|
||||
|
||||
// subscriber pairs an ID with its event handler and the per-subscriber worker
|
||||
// state used to deliver events to it serially, without blocking the dispatch loop.
|
||||
type subscriber struct {
|
||||
id string
|
||||
handler func(Event)
|
||||
queue chan Event
|
||||
quit chan struct{}
|
||||
}
|
||||
|
||||
// Bus is a minimal in-process pub/sub event bus backed by a buffered channel.
|
||||
// Producers call Publish (non-blocking) and every event is fanned out to all
|
||||
// subscribers; per-event filtering is the subscriber's responsibility.
|
||||
type Bus struct {
|
||||
ch chan Event
|
||||
subs []subscriber
|
||||
mu sync.RWMutex
|
||||
done chan struct{}
|
||||
wg sync.WaitGroup
|
||||
ch chan Event
|
||||
subs []*subscriber
|
||||
mu sync.RWMutex
|
||||
done chan struct{}
|
||||
wg sync.WaitGroup
|
||||
stopped bool
|
||||
}
|
||||
|
||||
// New creates a Bus with the given buffer size. Use 0 for DefaultBufferSize.
|
||||
@@ -41,27 +51,41 @@ func New(bufSize int) *Bus {
|
||||
return b
|
||||
}
|
||||
|
||||
// Subscribe registers a handler that receives every published event.
|
||||
// The id is used for Unsubscribe; it must be unique across active subscribers.
|
||||
// Subscribing with an already-registered id replaces the previous handler.
|
||||
// Subscribe registers a handler that receives every published event on its own
|
||||
// worker goroutine. The id is used for Unsubscribe; it must be unique across
|
||||
// active subscribers. Subscribing with an already-registered id replaces the
|
||||
// previous subscriber, stopping its worker.
|
||||
func (b *Bus) Subscribe(id string, handler func(Event)) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.stopped {
|
||||
return
|
||||
}
|
||||
for i, s := range b.subs {
|
||||
if s.id == id {
|
||||
b.subs[i].handler = handler
|
||||
return
|
||||
close(s.quit)
|
||||
b.subs = append(b.subs[:i], b.subs[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
b.subs = append(b.subs, subscriber{id: id, handler: handler})
|
||||
s := &subscriber{
|
||||
id: id,
|
||||
handler: handler,
|
||||
queue: make(chan Event, subscriberQueueSize),
|
||||
quit: make(chan struct{}),
|
||||
}
|
||||
b.subs = append(b.subs, s)
|
||||
b.wg.Add(1)
|
||||
go b.runWorker(s)
|
||||
}
|
||||
|
||||
// Unsubscribe removes a subscriber by id. Safe to call with unknown id.
|
||||
// Unsubscribe removes a subscriber by id and stops its worker. Safe to call with an unknown id.
|
||||
func (b *Bus) Unsubscribe(id string) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
for i, s := range b.subs {
|
||||
if s.id == id {
|
||||
close(s.quit)
|
||||
b.subs = append(b.subs[:i], b.subs[i+1:]...)
|
||||
return
|
||||
}
|
||||
@@ -81,9 +105,13 @@ func (b *Bus) Publish(e Event) {
|
||||
}
|
||||
}
|
||||
|
||||
// dispatch is the fan-out loop. It reads events from the channel and calls
|
||||
// every subscriber's handler sequentially. Handlers run on the dispatch
|
||||
// goroutine — they must not block.
|
||||
// dispatch is the fan-out loop. It reads events from the channel and hands each
|
||||
// one to every subscriber's queue with a non-blocking send, so a subscriber
|
||||
// whose handler blocks on network I/O (the email and Telegram notifiers can
|
||||
// block for tens of seconds) can neither stall delivery of unrelated, higher-
|
||||
// value events such as xray.crash or node.down, nor force the bus to spawn an
|
||||
// unbounded number of goroutines under load. A subscriber whose queue is full
|
||||
// drops the event, keeping the bus non-blocking and its memory bounded.
|
||||
func (b *Bus) dispatch() {
|
||||
defer b.wg.Done()
|
||||
for {
|
||||
@@ -93,12 +121,30 @@ func (b *Bus) dispatch() {
|
||||
return
|
||||
}
|
||||
b.mu.RLock()
|
||||
subs := make([]subscriber, len(b.subs))
|
||||
copy(subs, b.subs)
|
||||
b.mu.RUnlock()
|
||||
for _, s := range subs {
|
||||
safeCall(s.handler, e)
|
||||
for _, s := range b.subs {
|
||||
select {
|
||||
case s.queue <- e:
|
||||
default:
|
||||
logger.Warning("eventbus: subscriber ", s.id, " queue full, dropping ", e.Type)
|
||||
}
|
||||
}
|
||||
b.mu.RUnlock()
|
||||
case <-b.done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runWorker delivers queued events to one subscriber serially, so a subscriber
|
||||
// never runs concurrently with itself and observes events in publication order.
|
||||
func (b *Bus) runWorker(s *subscriber) {
|
||||
defer b.wg.Done()
|
||||
for {
|
||||
select {
|
||||
case e := <-s.queue:
|
||||
safeCall(s.handler, e)
|
||||
case <-s.quit:
|
||||
return
|
||||
case <-b.done:
|
||||
return
|
||||
}
|
||||
@@ -115,9 +161,15 @@ func safeCall(fn func(Event), e Event) {
|
||||
fn(e)
|
||||
}
|
||||
|
||||
// Stop shuts down the bus: the dispatch goroutine exits, in-flight handlers
|
||||
// finish, and any events still buffered may be dropped. Safe to call once.
|
||||
// Stop shuts down the bus: the dispatch loop and every subscriber worker exit
|
||||
// after finishing any handler already in progress, and any events still buffered
|
||||
// or queued may be dropped. Safe to call once. After Stop returns, Subscribe is
|
||||
// a no-op — this also keeps Subscribe's wg.Add from ever racing with Wait below,
|
||||
// since both are serialized through mu.
|
||||
func (b *Bus) Stop() {
|
||||
b.mu.Lock()
|
||||
b.stopped = true
|
||||
b.mu.Unlock()
|
||||
close(b.done)
|
||||
b.wg.Wait()
|
||||
}
|
||||
|
||||
@@ -149,6 +149,68 @@ func TestBusPanicRecovery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusBlockingSubscriberDoesNotStallOthers(t *testing.T) {
|
||||
b := New(16)
|
||||
defer b.Stop()
|
||||
|
||||
release := make(chan struct{})
|
||||
b.Subscribe("blocking", func(e Event) {
|
||||
<-release
|
||||
})
|
||||
|
||||
fast := make(chan struct{}, 1)
|
||||
b.Subscribe("fast", func(e Event) {
|
||||
fast <- struct{}{}
|
||||
})
|
||||
|
||||
b.Publish(Event{Type: EventXrayCrash})
|
||||
|
||||
select {
|
||||
case <-fast:
|
||||
case <-time.After(time.Second):
|
||||
close(release)
|
||||
t.Fatal("a blocking subscriber stalled event delivery to another subscriber")
|
||||
}
|
||||
close(release)
|
||||
}
|
||||
|
||||
func TestBusSubscriberRunsSerially(t *testing.T) {
|
||||
b := New(16)
|
||||
defer b.Stop()
|
||||
|
||||
var inFlight atomic.Int32
|
||||
var maxSeen atomic.Int32
|
||||
var wg sync.WaitGroup
|
||||
const n = 8
|
||||
wg.Add(n)
|
||||
|
||||
b.Subscribe("serial", func(Event) {
|
||||
cur := inFlight.Add(1)
|
||||
for {
|
||||
m := maxSeen.Load()
|
||||
if cur <= m || maxSeen.CompareAndSwap(m, cur) {
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
inFlight.Add(-1)
|
||||
wg.Done()
|
||||
})
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
b.Publish(Event{Type: EventXrayCrash})
|
||||
}
|
||||
|
||||
select {
|
||||
case <-waitDone(&wg):
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("subscriber did not process all events")
|
||||
}
|
||||
if got := maxSeen.Load(); got != 1 {
|
||||
t.Fatalf("subscriber ran concurrently with itself: max in-flight = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusBufferFull(t *testing.T) {
|
||||
b := New(2)
|
||||
defer b.Stop()
|
||||
@@ -198,3 +260,17 @@ func waitDone(wg *sync.WaitGroup) <-chan struct{} {
|
||||
}()
|
||||
return ch
|
||||
}
|
||||
|
||||
func TestBusSubscribeAfterStopIsNoop(t *testing.T) {
|
||||
b := New(4)
|
||||
b.Stop()
|
||||
|
||||
b.Subscribe("late", func(Event) {})
|
||||
|
||||
b.mu.RLock()
|
||||
n := len(b.subs)
|
||||
b.mu.RUnlock()
|
||||
if n != 0 {
|
||||
t.Fatalf("Subscribe after Stop registered %d subscriber(s), want 0 (a stopped bus must not accept new subscribers, and must not call wg.Add after wg.Wait has been entered)", n)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,16 +168,22 @@ func (s *SubClashService) getProxies(subReq *SubService, inbound *model.Inbound,
|
||||
|
||||
proxies := make([]map[string]any, 0, len(externalProxies))
|
||||
for _, ep := range externalProxies {
|
||||
extPrxy := ep.(map[string]any)
|
||||
extPrxy, ok := ep.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Expand the host's {{VAR}} remark template for this client (no-op for
|
||||
// the synthetic/legacy entry) before it becomes the proxy name.
|
||||
subReq.renderHostRemark(inbound, client, extPrxy, network)
|
||||
workingInbound := *inbound
|
||||
workingInbound.Listen = extPrxy["dest"].(string)
|
||||
workingInbound.Port = int(extPrxy["port"].(float64))
|
||||
workingInbound.Listen, _ = extPrxy["dest"].(string)
|
||||
if port, ok := extPrxy["port"].(float64); ok {
|
||||
workingInbound.Port = int(port)
|
||||
}
|
||||
workingStream := cloneStreamForExternalProxy(stream)
|
||||
|
||||
switch extPrxy["forceTls"].(string) {
|
||||
forceTls, _ := extPrxy["forceTls"].(string)
|
||||
switch forceTls {
|
||||
case "tls":
|
||||
if workingStream["security"] != "tls" {
|
||||
workingStream["security"] = "tls"
|
||||
@@ -684,6 +690,9 @@ func (s *SubClashService) applySecurity(proxy map[string]any, security string, s
|
||||
proxy["skip-cert-verify"] = true
|
||||
}
|
||||
}
|
||||
if pins, ok := tlsSettings["pin-sha256"].([]any); ok && len(pins) > 0 {
|
||||
proxy["pin-sha256"] = pins
|
||||
}
|
||||
}
|
||||
return true
|
||||
case "reality":
|
||||
|
||||
+43
-25
@@ -340,14 +340,12 @@ func (a *SUBController) subs(c *gin.Context) {
|
||||
logSubscriptionRoute(userAgent, "html")
|
||||
return
|
||||
}
|
||||
if shouldAutoServeClash(a.subClashAutoDetect, a.clashEnabled, false, userAgent, a.clashUserAgent) {
|
||||
if shouldAutoServeClash(a.subClashAutoDetect, a.clashEnabled, false, userAgent, a.clashUserAgent) && a.serveClashBody(c) {
|
||||
logSubscriptionRoute(userAgent, "clash")
|
||||
a.subClashs(c)
|
||||
return
|
||||
}
|
||||
if shouldAutoServeJson(a.jsonAutoDetect, a.jsonEnabled, false, userAgent, a.jsonUserAgent) {
|
||||
if shouldAutoServeJson(a.jsonAutoDetect, a.jsonEnabled, false, userAgent, a.jsonUserAgent) && a.serveJsonBody(c, true, "application/json; charset=utf-8") {
|
||||
logSubscriptionRoute(userAgent, "json")
|
||||
a.serveJson(c, true, "application/json; charset=utf-8")
|
||||
return
|
||||
}
|
||||
logSubscriptionRoute(userAgent, "raw")
|
||||
@@ -605,43 +603,63 @@ func (a *SUBController) subJsons(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (a *SUBController) serveJson(c *gin.Context, alwaysReturnArray bool, contentType string) {
|
||||
if !a.serveJsonBody(c, alwaysReturnArray, contentType) {
|
||||
writeSubError(c, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *SUBController) serveJsonBody(c *gin.Context, alwaysReturnArray bool, contentType string) bool {
|
||||
subId := c.Param("subid")
|
||||
scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
|
||||
jsonSub, header, err := a.subJsonService.GetJson(subId, host, alwaysReturnArray)
|
||||
if err != nil || len(jsonSub) == 0 {
|
||||
if err != nil {
|
||||
writeSubError(c, err)
|
||||
} else {
|
||||
profileUrl := a.subProfileUrl
|
||||
if profileUrl == "" {
|
||||
profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
|
||||
}
|
||||
a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
|
||||
|
||||
c.Data(200, contentType, []byte(jsonSub))
|
||||
return true
|
||||
}
|
||||
if len(jsonSub) == 0 {
|
||||
return false
|
||||
}
|
||||
profileUrl := a.subProfileUrl
|
||||
if profileUrl == "" {
|
||||
profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
|
||||
}
|
||||
a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
|
||||
|
||||
c.Data(200, contentType, []byte(jsonSub))
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *SUBController) subClashs(c *gin.Context) {
|
||||
if a.maybeServeSubPage(c) {
|
||||
return
|
||||
}
|
||||
if !a.serveClashBody(c) {
|
||||
writeSubError(c, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *SUBController) serveClashBody(c *gin.Context) bool {
|
||||
subId := c.Param("subid")
|
||||
scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
|
||||
clashSub, header, err := a.subClashService.GetClash(subId, host)
|
||||
if err != nil || len(clashSub) == 0 {
|
||||
if err != nil {
|
||||
writeSubError(c, err)
|
||||
} else {
|
||||
profileUrl := a.subProfileUrl
|
||||
if profileUrl == "" {
|
||||
profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
|
||||
}
|
||||
a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
|
||||
if a.subTitle != "" {
|
||||
// Clash clients commonly use Content-Disposition to choose the imported profile name.
|
||||
c.Writer.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename*=UTF-8''%s`, url.PathEscape(a.subTitle)))
|
||||
}
|
||||
c.Data(200, "application/yaml; charset=utf-8", []byte(clashSub))
|
||||
return true
|
||||
}
|
||||
if len(clashSub) == 0 {
|
||||
return false
|
||||
}
|
||||
profileUrl := a.subProfileUrl
|
||||
if profileUrl == "" {
|
||||
profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
|
||||
}
|
||||
a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
|
||||
if a.subTitle != "" {
|
||||
// Clash clients commonly use Content-Disposition to choose the imported profile name.
|
||||
c.Writer.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename*=UTF-8''%s`, url.PathEscape(a.subTitle)))
|
||||
}
|
||||
c.Data(200, "application/yaml; charset=utf-8", []byte(clashSub))
|
||||
return true
|
||||
}
|
||||
|
||||
// ApplyCommonHeaders sets common HTTP headers for subscription responses including user info, update interval, and profile title.
|
||||
|
||||
@@ -3,6 +3,7 @@ package sub
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -13,6 +14,8 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
@@ -185,6 +188,51 @@ func TestSanitizeUserAgentForLog(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func seedSubMtprotoInbound(t *testing.T, subId, tag string, port int) {
|
||||
t.Helper()
|
||||
db := database.GetDB()
|
||||
secret := "ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d"
|
||||
email := tag + "@e"
|
||||
settings := fmt.Sprintf(`{"clients":[{"email":%q,"subId":%q,"enable":true,"secret":%q}]}`, email, subId, secret)
|
||||
ib := &model.Inbound{
|
||||
UserId: 1, Tag: tag, Enable: true, Listen: "203.0.113.5", Port: port,
|
||||
Protocol: model.MTProto, Remark: tag, Settings: settings, StreamSettings: "{}",
|
||||
}
|
||||
if err := db.Create(ib).Error; err != nil {
|
||||
t.Fatalf("seed mtproto inbound %s: %v", tag, err)
|
||||
}
|
||||
client := &model.ClientRecord{Email: email, SubID: subId, Secret: secret, Enable: true}
|
||||
if err := db.Create(client).Error; err != nil {
|
||||
t.Fatalf("seed client %s: %v", email, err)
|
||||
}
|
||||
if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: ib.Id}).Error; err != nil {
|
||||
t.Fatalf("seed client_inbound %s: %v", email, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoDetectFallsBackToRawWhenFormatHasNoContent(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
seedSubMtprotoInbound(t, "s1", "tg", 4490)
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
|
||||
req.Header.Set("User-Agent", "Clash-Verge/v2.4.2")
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
newSubscriptionTestRouter(subscriptionTestRouterConfig{clashAutoDetect: true, jsonAutoDetect: true}).ServeHTTP(resp, req)
|
||||
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(resp.Body.String())
|
||||
if err != nil {
|
||||
t.Fatalf("fallback response is not base64: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(decoded), "tg://proxy") {
|
||||
t.Fatalf("decoded fallback lacks the Telegram proxy link: %s", decoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStandardSubscriptionAutoDetectsFormats(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
seedSubInbound(t, "s1", "auto", 4480, 1, `{"network":"tcp","security":"none"}`)
|
||||
|
||||
@@ -177,14 +177,20 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c
|
||||
network, _ := stream["network"].(string)
|
||||
|
||||
for _, ep := range externalProxies {
|
||||
extPrxy := ep.(map[string]any)
|
||||
extPrxy, ok := ep.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Expand the host's {{VAR}} remark template for this client (no-op for
|
||||
// the synthetic/legacy entry) before it's used as the config remark.
|
||||
subReq.renderHostRemark(inbound, client, extPrxy, network)
|
||||
inbound.Listen = extPrxy["dest"].(string)
|
||||
inbound.Port = int(extPrxy["port"].(float64))
|
||||
inbound.Listen, _ = extPrxy["dest"].(string)
|
||||
if port, ok := extPrxy["port"].(float64); ok {
|
||||
inbound.Port = int(port)
|
||||
}
|
||||
newStream := cloneStreamForExternalProxy(stream)
|
||||
switch extPrxy["forceTls"].(string) {
|
||||
forceTls, _ := extPrxy["forceTls"].(string)
|
||||
switch forceTls {
|
||||
case "tls":
|
||||
if newStream["security"] != "tls" {
|
||||
newStream["security"] = "tls"
|
||||
@@ -351,13 +357,13 @@ func (s *SubJsonService) realityData(rData map[string]any, clientKey string) map
|
||||
rltyData["spiderX"] = deriveSpiderX(seed, clientKey)
|
||||
shortIds, ok := rData["shortIds"].([]any)
|
||||
if ok && len(shortIds) > 0 {
|
||||
rltyData["shortId"] = shortIds[random.Num(len(shortIds))].(string)
|
||||
rltyData["shortId"], _ = shortIds[random.Num(len(shortIds))].(string)
|
||||
} else {
|
||||
rltyData["shortId"] = ""
|
||||
}
|
||||
serverNames, ok := rData["serverNames"].([]any)
|
||||
if ok && len(serverNames) > 0 {
|
||||
rltyData["serverName"] = serverNames[random.Num(len(serverNames))].(string)
|
||||
rltyData["serverName"], _ = serverNames[random.Num(len(serverNames))].(string)
|
||||
} else {
|
||||
rltyData["serverName"] = ""
|
||||
}
|
||||
@@ -496,7 +502,7 @@ func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any,
|
||||
}
|
||||
|
||||
_ = json.Unmarshal([]byte(inbound.StreamSettings), &stream)
|
||||
hyStream := stream["hysteriaSettings"].(map[string]any)
|
||||
hyStream, _ := stream["hysteriaSettings"].(map[string]any)
|
||||
outHyStream := map[string]any{
|
||||
"version": int(version),
|
||||
"auth": client.Auth,
|
||||
|
||||
@@ -124,23 +124,23 @@ func expandRemarkVars(template string, ctx remarkContext) string {
|
||||
}
|
||||
|
||||
// expandSegment expands one "|" segment and reports whether it should be dropped.
|
||||
// It drops only when the segment carries an unlimited (∞) quota/expiry token and
|
||||
// no other token in it resolves to a non-empty value — so a segment mixing, say,
|
||||
// {{EMAIL}} with {{TRAFFIC_LEFT}} is always kept.
|
||||
// A segment that contains tokens is dropped when none of them resolve to a real
|
||||
// value — whether because they render the unlimited (∞) mark or the empty string
|
||||
// — so it leaves no stray "|" separator or dangling decoration. A segment mixing,
|
||||
// say, {{EMAIL}} with {{TRAFFIC_LEFT}} is kept, and a pure-literal segment (no
|
||||
// tokens) is always kept.
|
||||
func expandSegment(seg string, ctx remarkContext) (string, bool) {
|
||||
hasUnlimited, hasOtherValue := false, false
|
||||
hasToken, hasOtherValue := false, false
|
||||
out := remarkVarRe.ReplaceAllStringFunc(seg, func(m string) string {
|
||||
hasToken = true
|
||||
token := m[2 : len(m)-2]
|
||||
val := remarkVarValue(token, ctx)
|
||||
switch {
|
||||
case unlimitedDropTokens[token] && val == unlimitedMark:
|
||||
hasUnlimited = true
|
||||
case val != "":
|
||||
if val != "" && (!unlimitedDropTokens[token] || val != unlimitedMark) {
|
||||
hasOtherValue = true
|
||||
}
|
||||
return val
|
||||
})
|
||||
return out, hasUnlimited && !hasOtherValue
|
||||
return out, hasToken && !hasOtherValue
|
||||
}
|
||||
|
||||
func remarkVarValue(token string, ctx remarkContext) string {
|
||||
|
||||
@@ -128,6 +128,19 @@ func TestExpandRemarkVars_DropUnlimitedSegments(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandRemarkVars_DropEmptySegments(t *testing.T) {
|
||||
inbound := &model.Inbound{Remark: "host"}
|
||||
|
||||
noComment := expandCtx(model.Client{}, xray.ClientTraffic{Enable: true}, inbound)
|
||||
if got := expandRemarkVars("{{INBOUND}}|{{COMMENT}}", noComment); got != "host" {
|
||||
t.Errorf("empty comment segment = %q, want %q (no trailing pipe)", got, "host")
|
||||
}
|
||||
|
||||
if got := expandRemarkVars("{{INBOUND}}|📅{{EXPIRE_DATE}}", noComment); got != "host" {
|
||||
t.Errorf("decorated empty segment = %q, want %q", got, "host")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientStatus(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
+40
-27
@@ -781,7 +781,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
|
||||
}
|
||||
uuid := client.ID
|
||||
port := inbound.Port
|
||||
streamNetwork := stream["network"].(string)
|
||||
streamNetwork, _ := stream["network"].(string)
|
||||
params := make(map[string]string)
|
||||
params["type"] = streamNetwork
|
||||
|
||||
@@ -840,7 +840,7 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
|
||||
}
|
||||
password := encodeUserinfo(client.Password)
|
||||
port := inbound.Port
|
||||
streamNetwork := stream["network"].(string)
|
||||
streamNetwork, _ := stream["network"].(string)
|
||||
params := make(map[string]string)
|
||||
params["type"] = streamNetwork
|
||||
|
||||
@@ -913,9 +913,9 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
|
||||
}
|
||||
|
||||
settings := s.linkSettings(inbound)
|
||||
inboundPassword := settings["password"].(string)
|
||||
method := settings["method"].(string)
|
||||
streamNetwork := stream["network"].(string)
|
||||
inboundPassword, _ := settings["password"].(string)
|
||||
method, _ := settings["method"].(string)
|
||||
streamNetwork, _ := stream["network"].(string)
|
||||
params := make(map[string]string)
|
||||
params["type"] = streamNetwork
|
||||
|
||||
@@ -993,7 +993,9 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
|
||||
alpns, _ := tlsSetting["alpn"].([]any)
|
||||
var alpn []string
|
||||
for _, a := range alpns {
|
||||
alpn = append(alpn, a.(string))
|
||||
if s, ok := a.(string); ok {
|
||||
alpn = append(alpn, s)
|
||||
}
|
||||
}
|
||||
if len(alpn) > 0 {
|
||||
params["alpn"] = strings.Join(alpn, ",")
|
||||
@@ -1180,7 +1182,7 @@ func unmarshalStreamSettings(streamSettings string) map[string]any {
|
||||
}
|
||||
|
||||
func applyPathAndHostParams(settings map[string]any, params map[string]string) {
|
||||
params["path"] = settings["path"].(string)
|
||||
params["path"], _ = settings["path"].(string)
|
||||
if host, ok := settings["host"].(string); ok && len(host) > 0 {
|
||||
params["host"] = host
|
||||
} else {
|
||||
@@ -1190,7 +1192,7 @@ func applyPathAndHostParams(settings map[string]any, params map[string]string) {
|
||||
}
|
||||
|
||||
func applyPathAndHostObj(settings map[string]any, obj map[string]any) {
|
||||
obj["path"] = settings["path"].(string)
|
||||
obj["path"], _ = settings["path"].(string)
|
||||
if host, ok := settings["host"].(string); ok && len(host) > 0 {
|
||||
obj["host"] = host
|
||||
} else {
|
||||
@@ -1206,9 +1208,11 @@ func applyShareNetworkParams(stream map[string]any, streamNetwork string, params
|
||||
header, _ := tcp["header"].(map[string]any)
|
||||
typeStr, _ := header["type"].(string)
|
||||
if typeStr == "http" {
|
||||
request := header["request"].(map[string]any)
|
||||
request, _ := header["request"].(map[string]any)
|
||||
requestPath, _ := request["path"].([]any)
|
||||
params["path"] = requestPath[0].(string)
|
||||
if len(requestPath) > 0 {
|
||||
params["path"], _ = requestPath[0].(string)
|
||||
}
|
||||
host := ""
|
||||
if response, ok := header["response"].(map[string]any); ok {
|
||||
if respHeaders, ok := response["headers"].(map[string]any); ok {
|
||||
@@ -1229,9 +1233,9 @@ func applyShareNetworkParams(stream map[string]any, streamNetwork string, params
|
||||
applyPathAndHostParams(ws, params)
|
||||
case "grpc":
|
||||
grpc, _ := stream["grpcSettings"].(map[string]any)
|
||||
params["serviceName"] = grpc["serviceName"].(string)
|
||||
params["serviceName"], _ = grpc["serviceName"].(string)
|
||||
params["authority"], _ = grpc["authority"].(string)
|
||||
if grpc["multiMode"].(bool) {
|
||||
if mm, _ := grpc["multiMode"].(bool); mm {
|
||||
params["mode"] = "multi"
|
||||
}
|
||||
case "httpupgrade":
|
||||
@@ -1262,9 +1266,11 @@ func applyVmessNetworkParams(stream map[string]any, network string, obj map[stri
|
||||
typeStr, _ := header["type"].(string)
|
||||
obj["type"] = typeStr
|
||||
if typeStr == "http" {
|
||||
request := header["request"].(map[string]any)
|
||||
request, _ := header["request"].(map[string]any)
|
||||
requestPath, _ := request["path"].([]any)
|
||||
obj["path"] = requestPath[0].(string)
|
||||
if len(requestPath) > 0 {
|
||||
obj["path"], _ = requestPath[0].(string)
|
||||
}
|
||||
host := ""
|
||||
if response, ok := header["response"].(map[string]any); ok {
|
||||
if respHeaders, ok := response["headers"].(map[string]any); ok {
|
||||
@@ -1284,9 +1290,9 @@ func applyVmessNetworkParams(stream map[string]any, network string, obj map[stri
|
||||
applyPathAndHostObj(ws, obj)
|
||||
case "grpc":
|
||||
grpc, _ := stream["grpcSettings"].(map[string]any)
|
||||
obj["path"] = grpc["serviceName"].(string)
|
||||
obj["authority"] = grpc["authority"].(string)
|
||||
if grpc["multiMode"].(bool) {
|
||||
obj["path"], _ = grpc["serviceName"].(string)
|
||||
obj["authority"], _ = grpc["authority"].(string)
|
||||
if mm, _ := grpc["multiMode"].(bool); mm {
|
||||
obj["type"] = "multi"
|
||||
}
|
||||
case "httpupgrade":
|
||||
@@ -1308,7 +1314,9 @@ func applyShareTLSParams(stream map[string]any, params map[string]string) {
|
||||
alpns, _ := tlsSetting["alpn"].([]any)
|
||||
var alpn []string
|
||||
for _, a := range alpns {
|
||||
alpn = append(alpn, a.(string))
|
||||
if s, ok := a.(string); ok {
|
||||
alpn = append(alpn, s)
|
||||
}
|
||||
}
|
||||
if len(alpn) > 0 {
|
||||
params["alpn"] = strings.Join(alpn, ",")
|
||||
@@ -1342,7 +1350,9 @@ func applyVmessTLSParams(stream map[string]any, obj map[string]any) {
|
||||
if len(alpns) > 0 {
|
||||
var alpn []string
|
||||
for _, a := range alpns {
|
||||
alpn = append(alpn, a.(string))
|
||||
if s, ok := a.(string); ok {
|
||||
alpn = append(alpn, s)
|
||||
}
|
||||
}
|
||||
obj["alpn"] = strings.Join(alpn, ",")
|
||||
}
|
||||
@@ -1451,15 +1461,17 @@ func applyShareRealityParams(stream map[string]any, params map[string]string, cl
|
||||
realitySettings, _ := searchKey(realitySetting, "settings")
|
||||
if realitySetting != nil {
|
||||
if sniValue, ok := searchKey(realitySetting, "serverNames"); ok {
|
||||
sNames, _ := sniValue.([]any)
|
||||
params["sni"] = sNames[random.Num(len(sNames))].(string)
|
||||
if sNames, _ := sniValue.([]any); len(sNames) > 0 {
|
||||
params["sni"], _ = sNames[random.Num(len(sNames))].(string)
|
||||
}
|
||||
}
|
||||
if pbkValue, ok := searchKey(realitySettings, "publicKey"); ok {
|
||||
params["pbk"], _ = pbkValue.(string)
|
||||
}
|
||||
if sidValue, ok := searchKey(realitySetting, "shortIds"); ok {
|
||||
shortIds, _ := sidValue.([]any)
|
||||
params["sid"] = shortIds[random.Num(len(shortIds))].(string)
|
||||
if shortIds, _ := sidValue.([]any); len(shortIds) > 0 {
|
||||
params["sid"], _ = shortIds[random.Num(len(shortIds))].(string)
|
||||
}
|
||||
}
|
||||
if fpValue, ok := searchKey(realitySettings, "fingerprint"); ok {
|
||||
if fp, ok := fpValue.(string); ok && len(fp) > 0 {
|
||||
@@ -2422,12 +2434,13 @@ func searchHost(headers any) string {
|
||||
case []any:
|
||||
hosts, _ := v.([]any)
|
||||
if len(hosts) > 0 {
|
||||
return hosts[0].(string)
|
||||
} else {
|
||||
return ""
|
||||
h, _ := hosts[0].(string)
|
||||
return h
|
||||
}
|
||||
return ""
|
||||
case any:
|
||||
return v.(string)
|
||||
h, _ := v.(string)
|
||||
return h
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func TestGetSubsToleratesUnusualStreamSettings(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
cases := []struct {
|
||||
name string
|
||||
stream string
|
||||
}{
|
||||
{"reality empty arrays", `{"network":"tcp","security":"reality","realitySettings":{"serverNames":[],"shortIds":[],"settings":{"publicKey":"pk"}}}`},
|
||||
{"tcp http missing request", `{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"http","response":{"headers":{}}}}}`},
|
||||
{"tcp http empty path", `{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"http","request":{"path":[]}}}}`},
|
||||
{"grpc missing keys", `{"network":"grpc","security":"none","grpcSettings":{}}`},
|
||||
{"empty stream settings", `{}`},
|
||||
{"ws missing wsSettings", `{"network":"ws","security":"none"}`},
|
||||
{"httpupgrade missing settings", `{"network":"httpupgrade","security":"none"}`},
|
||||
{"tls alpn non-string element", `{"network":"tcp","security":"tls","tlsSettings":{"alpn":[123]}}`},
|
||||
}
|
||||
|
||||
for i, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
subId := fmt.Sprintf("s%d", i)
|
||||
seedSubInbound(t, subId, fmt.Sprintf("t%d", i), 46000+i, 1, tc.stream)
|
||||
|
||||
links, _, _, _, err := NewSubService("").GetSubs(subId, "req.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSubs errored: %v", err)
|
||||
}
|
||||
if len(links) != 1 {
|
||||
t.Fatalf("expected 1 share link, got %d", len(links))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetJsonToleratesHysteriaWithoutHysteriaSettings(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
db := database.GetDB()
|
||||
|
||||
const subId = "hy1"
|
||||
const email = "hy@e"
|
||||
ib := &model.Inbound{
|
||||
UserId: 1, Tag: "hy", Enable: true, Listen: "203.0.113.5", Port: 46200,
|
||||
Protocol: model.Hysteria,
|
||||
Remark: "hy",
|
||||
Settings: fmt.Sprintf(`{"version":2,"clients":[{"auth":"hyauth","email":%q,"subId":%q,"enable":true}]}`, email, subId),
|
||||
StreamSettings: `{"security":"tls","tlsSettings":{"serverName":"hy.sni"}}`,
|
||||
}
|
||||
if err := db.Create(ib).Error; err != nil {
|
||||
t.Fatalf("seed inbound: %v", err)
|
||||
}
|
||||
client := &model.ClientRecord{Email: email, SubID: subId, Enable: true}
|
||||
if err := db.Create(client).Error; err != nil {
|
||||
t.Fatalf("seed client: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: ib.Id}).Error; err != nil {
|
||||
t.Fatalf("seed client_inbound: %v", err)
|
||||
}
|
||||
|
||||
jsonService := NewSubJsonService("", "", "", NewSubService(""))
|
||||
out, _, err := jsonService.GetJson(subId, "sub.example.com", true)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
}
|
||||
if out == "" {
|
||||
t.Fatal("GetJson returned empty for a hysteria inbound without hysteriaSettings")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetJsonToleratesNonStringRealityShortId(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
stream := `{"network":"tcp","security":"reality","realitySettings":{"serverNames":["sni.example.com"],"shortIds":[42],"settings":{"publicKey":"pk"}}}`
|
||||
seedSubInbound(t, "rlty1", "rlty", 46400, 1, stream)
|
||||
|
||||
jsonService := NewSubJsonService("", "", "", NewSubService(""))
|
||||
out, _, err := jsonService.GetJson("rlty1", "sub.example.com", true)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
}
|
||||
if out == "" {
|
||||
t.Fatal("GetJson returned empty for a reality inbound with a non-string shortId element")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetClashEmitsPinnedCertSha256(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
const pin = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
|
||||
stream := `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"pin.sni","settings":{"pinnedPeerCertSha256":["` + pin + `"]}}}`
|
||||
seedSubInbound(t, "pin1", "pin", 46300, 1, stream)
|
||||
|
||||
out, _, err := NewSubClashService(false, "", NewSubService("")).GetClash("pin1", "sub.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetClash: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "pin-sha256") {
|
||||
t.Fatalf("Clash proxy dropped the pinned cert sha256:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJsonAndClashTolerateExternalProxyMissingPort(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
stream := `{"network":"tcp","security":"none","externalProxy":[{"forceTls":"same","dest":"cdn.example.com"}]}`
|
||||
seedSubInbound(t, "extp1", "extp", 46500, 1, stream)
|
||||
|
||||
jsonService := NewSubJsonService("", "", "", NewSubService(""))
|
||||
jsonOut, _, err := jsonService.GetJson("extp1", "sub.example.com", true)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
}
|
||||
if jsonOut == "" {
|
||||
t.Fatal("GetJson returned empty for an externalProxy entry missing port")
|
||||
}
|
||||
|
||||
clashOut, _, err := NewSubClashService(false, "", NewSubService("")).GetClash("extp1", "sub.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetClash: %v", err)
|
||||
}
|
||||
if clashOut == "" {
|
||||
t.Fatal("GetClash returned empty for an externalProxy entry missing port")
|
||||
}
|
||||
}
|
||||
@@ -157,9 +157,8 @@ func parseVmess(link string) (*ParseResult, error) {
|
||||
// Map known fields (best effort, matching frontend parser coverage)
|
||||
switch network {
|
||||
case "ws":
|
||||
if host, ok := j["host"].(string); ok {
|
||||
setWS(stream, host, getString(j, "path", "/"))
|
||||
}
|
||||
host, _ := j["host"].(string)
|
||||
setWS(stream, host, getString(j, "path", "/"))
|
||||
case "grpc":
|
||||
svc := getString(j, "path", "")
|
||||
if auth, ok := j["authority"].(string); ok && auth != "" {
|
||||
@@ -452,7 +451,7 @@ func parseHysteria2(link string) (*ParseResult, error) {
|
||||
"alpn": splitCommaOrDefault(params.Get("alpn"), []string{"h3"}),
|
||||
"fingerprint": params.Get("fp"),
|
||||
"echConfigList": params.Get("ech"),
|
||||
"verifyPeerCertByName": "",
|
||||
"verifyPeerCertByName": params.Get("vcn"),
|
||||
"pinnedPeerCertSha256": params.Get("pinSHA256"),
|
||||
},
|
||||
}
|
||||
@@ -622,7 +621,17 @@ func applyTransport(stream map[string]any, p url.Values) {
|
||||
if m := p.Get("mode"); m != "" {
|
||||
xh["mode"] = m
|
||||
}
|
||||
// A few advanced xhttp fields that are commonly carried
|
||||
if v := p.Get("x_padding_bytes"); v != "" {
|
||||
xh["xPaddingBytes"] = v
|
||||
}
|
||||
if extra := p.Get("extra"); extra != "" {
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(extra), &parsed); err == nil {
|
||||
for k, v := range parsed {
|
||||
xh[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, k := range []string{"xPaddingBytes", "scMaxEachPostBytes", "scMinPostsIntervalMs", "uplinkChunkSize"} {
|
||||
if v := p.Get(k); v != "" {
|
||||
xh[k] = v
|
||||
|
||||
@@ -156,6 +156,51 @@ func TestParse_WSAndGRPCTransport(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_XhttpExtraAndSnakeCaseFields(t *testing.T) {
|
||||
q := url.Values{}
|
||||
q.Set("type", "xhttp")
|
||||
q.Set("encryption", "none")
|
||||
q.Set("security", "none")
|
||||
q.Set("mode", "auto")
|
||||
q.Set("x_padding_bytes", "1-50")
|
||||
q.Set("extra", `{"mode":"auto","xPaddingBytes":"1-50","scMaxEachPostBytes":"1000000"}`)
|
||||
res, err := ParseLink("vless://uuid@h.com:443?" + q.Encode() + "#r")
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
xh := streamSub(t, res, "xhttpSettings")
|
||||
if xh["xPaddingBytes"] != "1-50" {
|
||||
t.Errorf("xPaddingBytes = %v, want 1-50 (dropped from the snake_case/extra payload the emitter writes)", xh["xPaddingBytes"])
|
||||
}
|
||||
if xh["scMaxEachPostBytes"] != "1000000" {
|
||||
t.Errorf("scMaxEachPostBytes = %v, want 1000000 (dropped from the extra blob)", xh["scMaxEachPostBytes"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_VmessWSPathWithoutHostKey(t *testing.T) {
|
||||
inner := `{"v":"2","add":"h","port":443,"id":"11111111-2222-4333-8444-555555555555","net":"ws","path":"/api","tls":"tls"}`
|
||||
link := "vmess://" + base64.StdEncoding.EncodeToString([]byte(inner))
|
||||
res, err := ParseLink(link)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
wss := streamSub(t, res, "wsSettings")
|
||||
if wss["path"] != "/api" {
|
||||
t.Errorf("wsSettings path = %v, want /api (dropped when host key absent)", wss["path"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_Hysteria2VerifyPeerCertByName(t *testing.T) {
|
||||
res, err := ParseLink("hysteria2://auth@h.com:443?security=tls&sni=decoy.com&vcn=real-cert.com#r")
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
tls := streamSub(t, res, "tlsSettings")
|
||||
if tls["verifyPeerCertByName"] != "real-cert.com" {
|
||||
t.Errorf("verifyPeerCertByName = %v, want real-cert.com (vcn param ignored)", tls["verifyPeerCertByName"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_TCPHTTPHeader(t *testing.T) {
|
||||
res, err := ParseLink("vless://uuid@h.com:443?type=tcp&headerType=http&host=ex.com&path=%2F")
|
||||
if err != nil {
|
||||
|
||||
@@ -10,6 +10,10 @@ const (
|
||||
loginLimitMaxFailures = 5
|
||||
loginLimitWindow = 5 * time.Minute
|
||||
loginLimitCooldown = 15 * time.Minute
|
||||
// Hard ceiling on tracked (ip, username) records. The key includes the
|
||||
// caller-supplied username, so an unauthenticated attacker rotating
|
||||
// usernames would otherwise grow the map without bound.
|
||||
loginLimitMaxRecords = 10000
|
||||
)
|
||||
|
||||
var defaultLoginLimiter = newLoginLimiter(loginLimitMaxFailures, loginLimitWindow, loginLimitCooldown)
|
||||
@@ -63,13 +67,14 @@ func (l *loginLimiter) registerFailure(ip, username string) (time.Time, bool) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
now := l.now()
|
||||
key := loginLimitKey(ip, username)
|
||||
record := l.attempts[key]
|
||||
if record == nil {
|
||||
l.evictForRoom(now)
|
||||
record = &loginLimitRecord{}
|
||||
l.attempts[key] = record
|
||||
}
|
||||
now := l.now()
|
||||
record.failures = pruneLoginFailures(record.failures, now.Add(-l.window))
|
||||
record.failures = append(record.failures, now)
|
||||
if len(record.failures) >= l.maxFailures {
|
||||
@@ -86,6 +91,41 @@ func (l *loginLimiter) registerSuccess(ip, username string) {
|
||||
delete(l.attempts, loginLimitKey(ip, username))
|
||||
}
|
||||
|
||||
// evictForRoom keeps the attempts map bounded before inserting a new record.
|
||||
// It first reclaims records that are no longer blocked and whose failures have
|
||||
// aged out of the window; if the map is still at the ceiling (a genuine
|
||||
// broad flood), it drops one arbitrary record so memory can never grow past the
|
||||
// cap. Callers hold l.mu.
|
||||
func (l *loginLimiter) evictForRoom(now time.Time) {
|
||||
if len(l.attempts) < loginLimitMaxRecords {
|
||||
return
|
||||
}
|
||||
cutoff := now.Add(-l.window)
|
||||
for key, record := range l.attempts {
|
||||
if now.Before(record.blockedUntil) {
|
||||
continue
|
||||
}
|
||||
record.failures = pruneLoginFailures(record.failures, cutoff)
|
||||
if len(record.failures) == 0 {
|
||||
delete(l.attempts, key)
|
||||
}
|
||||
}
|
||||
if len(l.attempts) < loginLimitMaxRecords {
|
||||
return
|
||||
}
|
||||
for key, record := range l.attempts {
|
||||
if now.Before(record.blockedUntil) {
|
||||
continue
|
||||
}
|
||||
delete(l.attempts, key)
|
||||
return
|
||||
}
|
||||
for key := range l.attempts {
|
||||
delete(l.attempts, key)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func loginLimitKey(ip, username string) string {
|
||||
return strings.TrimSpace(ip) + "\x00" + strings.ToLower(strings.TrimSpace(username))
|
||||
}
|
||||
|
||||
@@ -1,10 +1,56 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoginLimiterBoundsMemoryUnderUsernameFlood(t *testing.T) {
|
||||
limiter := newLoginLimiter(5, 5*time.Minute, 15*time.Minute)
|
||||
for i := 0; i < loginLimitMaxRecords+100; i++ {
|
||||
limiter.registerFailure("1.2.3.4", "user-"+strconv.Itoa(i))
|
||||
}
|
||||
|
||||
limiter.mu.Lock()
|
||||
n := len(limiter.attempts)
|
||||
limiter.mu.Unlock()
|
||||
|
||||
if n > loginLimitMaxRecords {
|
||||
t.Fatalf("attempts map grew to %d, exceeding the %d ceiling under a username flood", n, loginLimitMaxRecords)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginLimiterEvictionSparesActiveBlocks(t *testing.T) {
|
||||
now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)
|
||||
limiter := newLoginLimiter(5, 5*time.Minute, 15*time.Minute)
|
||||
limiter.now = func() time.Time { return now }
|
||||
|
||||
limiter.mu.Lock()
|
||||
for i := 0; i < loginLimitMaxRecords-1; i++ {
|
||||
limiter.attempts["victim-"+strconv.Itoa(i)] = &loginLimitRecord{blockedUntil: now.Add(10 * time.Minute)}
|
||||
}
|
||||
limiter.attempts["filler"] = &loginLimitRecord{failures: []time.Time{now}}
|
||||
limiter.mu.Unlock()
|
||||
|
||||
if _, blocked := limiter.registerFailure("9.9.9.9", "newcomer"); blocked {
|
||||
t.Fatal("the eviction-triggering failure itself should not be blocked yet")
|
||||
}
|
||||
|
||||
limiter.mu.Lock()
|
||||
defer limiter.mu.Unlock()
|
||||
survivors := 0
|
||||
for key, record := range limiter.attempts {
|
||||
if strings.HasPrefix(key, "victim-") && now.Before(record.blockedUntil) {
|
||||
survivors++
|
||||
}
|
||||
}
|
||||
if survivors != loginLimitMaxRecords-1 {
|
||||
t.Fatalf("eviction under a full map dropped an actively-blocked record: %d/%d victims survived", survivors, loginLimitMaxRecords-1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginLimiterBlocksAfterConfiguredFailures(t *testing.T) {
|
||||
now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)
|
||||
limiter := newLoginLimiter(5, 5*time.Minute, 15*time.Minute)
|
||||
|
||||
@@ -27,3 +27,15 @@ func TestCheckValidSmtpFrom(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckValidWildcardListenPortConflict(t *testing.T) {
|
||||
s := &AllSetting{WebPort: 2053, SubPort: 2053, WebListen: "0.0.0.0", SubListen: ""}
|
||||
if err := s.CheckValid(); err == nil {
|
||||
t.Error("CheckValid must reject the same port bound on 0.0.0.0 and \"\" (both wildcard)")
|
||||
}
|
||||
|
||||
ok := &AllSetting{WebPort: 2053, SubPort: 2053, WebListen: "127.0.0.1", SubListen: "192.168.1.1"}
|
||||
if err := ok.CheckValid(); err != nil {
|
||||
t.Errorf("distinct specific listens on the same port should be allowed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ func (s *AllSetting) CheckValid() error {
|
||||
return common.NewError("Sub port is not a valid port:", s.SubPort)
|
||||
}
|
||||
|
||||
if (s.SubPort == s.WebPort) && (s.WebListen == s.SubListen) {
|
||||
if (s.SubPort == s.WebPort) && listenAddressesConflict(s.WebListen, s.SubListen) {
|
||||
return common.NewError("Sub and Web could not use same ip:port, ", s.SubListen, ":", s.SubPort, " & ", s.WebListen, ":", s.WebPort)
|
||||
}
|
||||
|
||||
@@ -258,6 +258,27 @@ func (s *AllSetting) CheckValid() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// listenAddressesConflict reports whether two listen addresses on the same port
|
||||
// would collide at bind time. A wildcard listen ("", "0.0.0.0", "::") overlaps
|
||||
// every address, so it conflicts with anything on that port; two specific
|
||||
// addresses conflict only when identical.
|
||||
func listenAddressesConflict(a, b string) bool {
|
||||
if a == b {
|
||||
return true
|
||||
}
|
||||
return isWildcardListen(a) || isWildcardListen(b)
|
||||
}
|
||||
|
||||
func isWildcardListen(listen string) bool {
|
||||
if listen == "" {
|
||||
return true
|
||||
}
|
||||
if ip := net.ParseIP(listen); ip != nil {
|
||||
return ip.IsUnspecified()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type HostGroup struct {
|
||||
GroupId string `json:"groupId"`
|
||||
InboundIds []int `json:"inboundIds" validate:"required,min=1"`
|
||||
|
||||
@@ -18,6 +18,7 @@ type Manager struct {
|
||||
mu sync.RWMutex
|
||||
remotes map[int]*Remote
|
||||
overrides map[int]Runtime // test-only: forces RuntimeFor to return a stub
|
||||
localOverride Runtime // test-only: forces RuntimeFor(nil) to return a stub
|
||||
egressResolver NodeEgressResolver
|
||||
}
|
||||
|
||||
@@ -44,6 +45,15 @@ func (m *Manager) SetRuntimeOverride(nodeID int, rt Runtime) {
|
||||
m.overrides[nodeID] = rt
|
||||
}
|
||||
|
||||
// SetLocalRuntimeOverride makes RuntimeFor(nil) return rt instead of the real
|
||||
// local runtime. Test seam for exercising the local dispatch path (MTProto
|
||||
// sidecar, local Xray) without a running child process; pass nil rt to clear.
|
||||
func (m *Manager) SetLocalRuntimeOverride(rt Runtime) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.localOverride = rt
|
||||
}
|
||||
|
||||
func (m *Manager) SetNodeEgressResolver(r NodeEgressResolver) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -61,6 +71,13 @@ func (m *Manager) NodeEgressProxyURL(nodeID int) string {
|
||||
|
||||
func (m *Manager) RuntimeFor(nodeID *int) (Runtime, error) {
|
||||
if nodeID == nil {
|
||||
m.mu.RLock()
|
||||
if m.localOverride != nil {
|
||||
rt := m.localOverride
|
||||
m.mu.RUnlock()
|
||||
return rt, nil
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
return m.local, nil
|
||||
}
|
||||
m.mu.RLock()
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseAccessLogFields(t *testing.T) {
|
||||
malformed := []string{
|
||||
"",
|
||||
"singletoken",
|
||||
"2024/01/02",
|
||||
"2024/01/02 15:04:05.000000 from",
|
||||
"2024/01/02 15:04:05.000000 accepted",
|
||||
"2024/01/02 15:04:05.000000 email:",
|
||||
}
|
||||
for _, line := range malformed {
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("parseAccessLogFields panicked on %q: %v", line, r)
|
||||
}
|
||||
}()
|
||||
_ = parseAccessLogFields(line)
|
||||
}()
|
||||
}
|
||||
|
||||
line := "2024/01/02 15:04:05.123456 from 1.2.3.4:555 accepted tcp:example.com:443 [inbound-tag >> outbound-tag] email: alice@example.com"
|
||||
entry := parseAccessLogFields(line)
|
||||
if entry.FromAddress != "1.2.3.4:555" {
|
||||
t.Errorf("FromAddress = %q, want %q", entry.FromAddress, "1.2.3.4:555")
|
||||
}
|
||||
if entry.ToAddress != "tcp:example.com:443" {
|
||||
t.Errorf("ToAddress = %q, want %q", entry.ToAddress, "tcp:example.com:443")
|
||||
}
|
||||
if entry.Inbound != "inbound-tag" {
|
||||
t.Errorf("Inbound = %q, want %q", entry.Inbound, "inbound-tag")
|
||||
}
|
||||
if entry.Outbound != "outbound-tag" {
|
||||
t.Errorf("Outbound = %q, want %q", entry.Outbound, "outbound-tag")
|
||||
}
|
||||
if entry.Email != "alice@example.com" {
|
||||
t.Errorf("Email = %q, want %q", entry.Email, "alice@example.com")
|
||||
}
|
||||
if entry.DateTime.IsZero() {
|
||||
t.Error("DateTime was not parsed from a well-formed line")
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,62 @@ func TestDelDepletedRemovesOnlyDepleted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientDeleteKeepTrafficPreservesRowForAttachedClient(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
email := "keepme@x"
|
||||
c := model.Client{Email: email, ID: "11111111-1111-1111-1111-111111111111", SubID: email, Enable: true}
|
||||
ib := mkInbound(t, 52030, model.VLESS, clientsSettings(t, []model.Client{c}))
|
||||
if err := svc.SyncInbound(nil, ib.Id, []model.Client{c}); err != nil {
|
||||
t.Fatalf("seed linkage: %v", err)
|
||||
}
|
||||
mkTraffic(t, ib.Id, email, 111, 222, 0, 0, true)
|
||||
|
||||
rec := lookupClientRecord(t, email)
|
||||
if _, err := svc.Delete(inboundSvc, rec.Id, true); err != nil {
|
||||
t.Fatalf("Delete(keepTraffic): %v", err)
|
||||
}
|
||||
|
||||
var cnt int64
|
||||
if err := database.GetDB().Model(&xray.ClientTraffic{}).Where("email = ?", email).Count(&cnt).Error; err != nil {
|
||||
t.Fatalf("count traffic: %v", err)
|
||||
}
|
||||
if cnt != 1 {
|
||||
t.Fatalf("keepTraffic delete of an inbound-attached client must preserve its client_traffics row, found %d", cnt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkDeleteRemovesClientExternalLinks(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
email := "extlink@x"
|
||||
c := model.Client{Email: email, ID: "11111111-1111-1111-1111-111111111111", SubID: email, Enable: true}
|
||||
ib := mkInbound(t, 52040, model.VLESS, clientsSettings(t, []model.Client{c}))
|
||||
if err := svc.SyncInbound(nil, ib.Id, []model.Client{c}); err != nil {
|
||||
t.Fatalf("seed linkage: %v", err)
|
||||
}
|
||||
rec := lookupClientRecord(t, email)
|
||||
if err := database.GetDB().Create(&model.ClientExternalLink{ClientId: rec.Id, Kind: "sub", Value: "https://example.com/x"}).Error; err != nil {
|
||||
t.Fatalf("seed external link: %v", err)
|
||||
}
|
||||
|
||||
if _, _, err := svc.BulkDelete(inboundSvc, []string{email}, false); err != nil {
|
||||
t.Fatalf("BulkDelete: %v", err)
|
||||
}
|
||||
|
||||
var cnt int64
|
||||
if err := database.GetDB().Model(&model.ClientExternalLink{}).Where("client_id = ?", rec.Id).Count(&cnt).Error; err != nil {
|
||||
t.Fatalf("count external links: %v", err)
|
||||
}
|
||||
if cnt != 0 {
|
||||
t.Fatalf("BulkDelete left %d orphan external-link row(s) behind", cnt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetClientTrafficByEmailReadsClientsTable(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
// TestResetClientExpiryTimeByEmail_MultiInbound reproduces #5039: a client
|
||||
@@ -86,3 +87,65 @@ func TestResetClientExpiryTimeByEmail_MultiInbound(t *testing.T) {
|
||||
t.Errorf("client record expiry = %d, want %d", rec.ExpiryTime, newExpiry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetClientEnableByEmail_MultiInbound(t *testing.T) {
|
||||
dbDir := t.TempDir()
|
||||
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
|
||||
db := database.GetDB()
|
||||
|
||||
const email = "multienable@example.com"
|
||||
const uid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c222"
|
||||
clientJSON := `{"clients":[{"email":"` + email + `","id":"` + uid + `","enable":true,"subId":"sub-en-1"}]}`
|
||||
|
||||
first := &model.Inbound{
|
||||
Tag: "vless-en-a", Enable: true, Port: 50011, Protocol: model.VLESS,
|
||||
StreamSettings: `{"network":"tcp","security":"reality"}`, Settings: clientJSON,
|
||||
}
|
||||
second := &model.Inbound{
|
||||
Tag: "vless-en-b", Enable: true, Port: 50012, Protocol: model.VLESS,
|
||||
StreamSettings: `{"network":"ws","security":"tls"}`, Settings: clientJSON,
|
||||
}
|
||||
for _, ib := range []*model.Inbound{first, second} {
|
||||
if err := db.Create(ib).Error; err != nil {
|
||||
t.Fatalf("create inbound %s: %v", ib.Tag, err)
|
||||
}
|
||||
}
|
||||
|
||||
clientSvc := ClientService{}
|
||||
inboundSvc := InboundService{}
|
||||
for _, ib := range []*model.Inbound{first, second} {
|
||||
clients, err := inboundSvc.GetClients(ib)
|
||||
if err != nil {
|
||||
t.Fatalf("GetClients(%s): %v", ib.Tag, err)
|
||||
}
|
||||
if err := clientSvc.SyncInbound(nil, ib.Id, clients); err != nil {
|
||||
t.Fatalf("SyncInbound(%s): %v", ib.Tag, err)
|
||||
}
|
||||
}
|
||||
if err := db.Create(&xray.ClientTraffic{InboundId: first.Id, Email: email, Enable: true}).Error; err != nil {
|
||||
t.Fatalf("seed traffic: %v", err)
|
||||
}
|
||||
|
||||
if _, _, err := clientSvc.SetClientEnableByEmail(&inboundSvc, email, false); err != nil {
|
||||
t.Fatalf("SetClientEnableByEmail: %v", err)
|
||||
}
|
||||
|
||||
for _, ib := range []*model.Inbound{first, second} {
|
||||
fresh, err := inboundSvc.GetInbound(ib.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetInbound(%s): %v", ib.Tag, err)
|
||||
}
|
||||
clients, err := inboundSvc.GetClients(fresh)
|
||||
if err != nil {
|
||||
t.Fatalf("GetClients(%s): %v", ib.Tag, err)
|
||||
}
|
||||
if len(clients) != 1 || clients[0].Enable {
|
||||
t.Errorf("inbound %s: client still enabled after disable-by-email; a sibling inbound kept access", ib.Tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,9 +359,15 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
|
||||
skippedReasons[email] = "unlimited traffic"
|
||||
}
|
||||
} else {
|
||||
next := max(rec.TotalGB+addBytes, 0)
|
||||
entry.applyTotal = true
|
||||
entry.newTotal = next
|
||||
next := rec.TotalGB + addBytes
|
||||
if next <= 0 {
|
||||
if _, exists := skippedReasons[email]; !exists {
|
||||
skippedReasons[email] = "reduction exceeds remaining quota"
|
||||
}
|
||||
} else {
|
||||
entry.applyTotal = true
|
||||
entry.newTotal = next
|
||||
}
|
||||
}
|
||||
}
|
||||
if entry.applyExpiry || entry.applyTotal || adjustFlow {
|
||||
@@ -403,6 +409,7 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
|
||||
needRestart := false
|
||||
flowHonored := map[string]bool{}
|
||||
flowIneligible := map[string]bool{}
|
||||
execFailed := map[string]bool{}
|
||||
for inboundId, ibEmails := range emailsByInbound {
|
||||
ibRes := s.bulkAdjustInboundClients(inboundSvc, inboundId, ibEmails, plan, flow)
|
||||
if ibRes.needRestart {
|
||||
@@ -415,6 +422,7 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
|
||||
flowIneligible[email] = true
|
||||
}
|
||||
for email, reason := range ibRes.perEmailSkipped {
|
||||
execFailed[email] = true
|
||||
if _, already := skippedReasons[email]; !already {
|
||||
skippedReasons[email] = reason
|
||||
}
|
||||
@@ -444,7 +452,7 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
|
||||
|
||||
adjusted := map[string]struct{}{}
|
||||
for email, entry := range plan {
|
||||
if _, skipped := skippedReasons[email]; skipped {
|
||||
if execFailed[email] {
|
||||
continue
|
||||
}
|
||||
updates := map[string]any{}
|
||||
@@ -815,7 +823,7 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string,
|
||||
|
||||
needRestart := false
|
||||
for inboundId, ibEmails := range emailsByInbound {
|
||||
ibResult := s.bulkDelInboundClients(inboundSvc, inboundId, ibEmails, recordsByEmail, false)
|
||||
ibResult := s.bulkDelInboundClients(inboundSvc, inboundId, ibEmails, recordsByEmail, keepTraffic)
|
||||
if ibResult.needRestart {
|
||||
needRestart = true
|
||||
}
|
||||
@@ -847,6 +855,9 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string,
|
||||
if e := tx.Where("client_id IN ?", batch).Delete(&model.ClientInbound{}).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
if e := tx.Where("client_id IN ?", batch).Delete(&model.ClientExternalLink{}).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
if !keepTraffic && len(successEmails) > 0 {
|
||||
for _, batch := range chunkStrings(successEmails, sqlInChunk) {
|
||||
|
||||
@@ -164,6 +164,52 @@ func TestBulkAdjust_ReenablesOverQuota_WhenAddBytesClearsQuota(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkAdjust_QuotaReductionBelowZeroSkipsInsteadOfUnlimited(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
email := "qr@x"
|
||||
c := model.Client{Email: email, ID: "11111111-1111-1111-1111-111111111111", SubID: email, Enable: true, TotalGB: 10}
|
||||
ib := mkInbound(t, 52020, model.VLESS, clientsSettings(t, []model.Client{c}))
|
||||
if err := svc.SyncInbound(nil, ib.Id, []model.Client{c}); err != nil {
|
||||
t.Fatalf("seed linkage: %v", err)
|
||||
}
|
||||
mkTraffic(t, ib.Id, email, 0, 0, 10, 0, true)
|
||||
|
||||
res, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 0, -20, "")
|
||||
if err != nil {
|
||||
t.Fatalf("BulkAdjust: %v", err)
|
||||
}
|
||||
if res.Adjusted != 0 {
|
||||
t.Fatalf("over-reduction should not adjust, got Adjusted=%d skipped=%v", res.Adjusted, res.Skipped)
|
||||
}
|
||||
if got := trafficOf(t, email).Total; got != 10 {
|
||||
t.Fatalf("quota reduced past zero was written as %d (0 means unlimited); want it left at 10", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkAdjust_AppliedFieldReachesTrafficRowDespiteOtherFieldSkip(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
email := "mix2@x"
|
||||
c := model.Client{Email: email, ID: "11111111-1111-1111-1111-111111111111", SubID: email, Enable: true, TotalGB: 100, ExpiryTime: 0}
|
||||
ib := mkInbound(t, 52021, model.VLESS, clientsSettings(t, []model.Client{c}))
|
||||
if err := svc.SyncInbound(nil, ib.Id, []model.Client{c}); err != nil {
|
||||
t.Fatalf("seed linkage: %v", err)
|
||||
}
|
||||
mkTraffic(t, ib.Id, email, 0, 0, 100, 0, true)
|
||||
|
||||
if _, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 30, 50, ""); err != nil {
|
||||
t.Fatalf("BulkAdjust: %v", err)
|
||||
}
|
||||
if got := trafficOf(t, email).Total; got != 150 {
|
||||
t.Fatalf("client_traffics.total = %d, want 150: the applied quota adjustment must reach the enforcement row even though the unlimited-expiry field was skipped", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkAdjust_OverQuota_DaysOnly_StaysDisabled(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
|
||||
@@ -510,7 +510,7 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
|
||||
if existing.Email == "" {
|
||||
continue
|
||||
}
|
||||
nr, delErr := s.DelInboundClientByEmail(inboundSvc, ibId, existing.Email, false, true)
|
||||
nr, delErr := s.DelInboundClientByEmail(inboundSvc, ibId, existing.Email, keepTraffic, true)
|
||||
if delErr != nil {
|
||||
// The client is already absent from this inbound (data drift or a
|
||||
// retried delete). Skip it — deletion stays idempotent.
|
||||
@@ -678,7 +678,7 @@ func (s *ClientService) DeleteByEmail(inboundSvc *InboundService, email string,
|
||||
needRestart := false
|
||||
var delErrs []error
|
||||
for _, ibId := range inboundIds {
|
||||
nr, delErr := s.DelInboundClientByEmail(inboundSvc, ibId, email, false, true)
|
||||
nr, delErr := s.DelInboundClientByEmail(inboundSvc, ibId, email, keepTraffic, true)
|
||||
if delErr != nil {
|
||||
if errors.Is(delErr, ErrClientNotInInbound) {
|
||||
continue
|
||||
|
||||
@@ -1142,62 +1142,18 @@ func (s *ClientService) CheckIsEnabledByEmail(inboundSvc *InboundService, client
|
||||
}
|
||||
|
||||
func (s *ClientService) ToggleClientEnableByEmail(inboundSvc *InboundService, clientEmail string) (bool, bool, error) {
|
||||
_, inbound, err := inboundSvc.GetClientInboundByEmail(clientEmail)
|
||||
current, err := s.CheckIsEnabledByEmail(inboundSvc, clientEmail)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
if inbound == nil {
|
||||
return false, false, common.NewError("Inbound Not Found For Email:", clientEmail)
|
||||
}
|
||||
|
||||
oldClients, err := inboundSvc.GetClients(inbound)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
|
||||
found := false
|
||||
clientOldEnabled := false
|
||||
|
||||
for _, oldClient := range oldClients {
|
||||
if oldClient.Email == clientEmail {
|
||||
found = true
|
||||
clientOldEnabled = oldClient.Enable
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return false, false, common.NewError("Client Not Found For Email:", clientEmail)
|
||||
}
|
||||
|
||||
var settings map[string]any
|
||||
err = json.Unmarshal([]byte(inbound.Settings), &settings)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
clients := settings["clients"].([]any)
|
||||
var newClients []any
|
||||
for client_index := range clients {
|
||||
c := clients[client_index].(map[string]any)
|
||||
if c["email"] == clientEmail {
|
||||
c["enable"] = !clientOldEnabled
|
||||
c["updated_at"] = time.Now().Unix() * 1000
|
||||
newClients = append(newClients, any(c))
|
||||
}
|
||||
}
|
||||
settings["clients"] = newClients
|
||||
modifiedSettings, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
inbound.Settings = string(modifiedSettings)
|
||||
|
||||
needRestart, err := s.UpdateInboundClient(inboundSvc, inbound, clientEmail)
|
||||
target := !current
|
||||
needRestart, err := s.applyClientFieldByEmail(inboundSvc, clientEmail, func(c map[string]any) {
|
||||
c["enable"] = target
|
||||
})
|
||||
if err != nil {
|
||||
return false, needRestart, err
|
||||
}
|
||||
|
||||
return !clientOldEnabled, needRestart, nil
|
||||
return target, needRestart, nil
|
||||
}
|
||||
|
||||
func (s *ClientService) SetClientEnableByEmail(inboundSvc *InboundService, clientEmail string, enable bool) (bool, bool, error) {
|
||||
@@ -1208,11 +1164,13 @@ func (s *ClientService) SetClientEnableByEmail(inboundSvc *InboundService, clien
|
||||
if current == enable {
|
||||
return false, false, nil
|
||||
}
|
||||
newEnabled, needRestart, err := s.ToggleClientEnableByEmail(inboundSvc, clientEmail)
|
||||
needRestart, err := s.applyClientFieldByEmail(inboundSvc, clientEmail, func(c map[string]any) {
|
||||
c["enable"] = enable
|
||||
})
|
||||
if err != nil {
|
||||
return false, needRestart, err
|
||||
}
|
||||
return newEnabled == enable, needRestart, nil
|
||||
return true, needRestart, nil
|
||||
}
|
||||
|
||||
// applyClientFieldByEmail loads the inbound currently hosting clientEmail,
|
||||
|
||||
@@ -28,12 +28,12 @@ var (
|
||||
|
||||
func lockInbound(inboundId int) *sync.Mutex {
|
||||
inboundMutationLocksMu.Lock()
|
||||
defer inboundMutationLocksMu.Unlock()
|
||||
m, ok := inboundMutationLocks[inboundId]
|
||||
if !ok {
|
||||
m = &sync.Mutex{}
|
||||
inboundMutationLocks[inboundId] = m
|
||||
}
|
||||
inboundMutationLocksMu.Unlock()
|
||||
m.Lock()
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLockInboundReleasesRegistryMutexWhileWaiting(t *testing.T) {
|
||||
const id = 990006
|
||||
held := lockInbound(id)
|
||||
|
||||
parked := make(chan struct{})
|
||||
go func() {
|
||||
close(parked)
|
||||
lockInbound(id).Unlock()
|
||||
}()
|
||||
<-parked
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
if !inboundMutationLocksMu.TryLock() {
|
||||
held.Unlock()
|
||||
t.Fatal("registry mutex is held while a lockInbound caller waits on a busy inbound")
|
||||
}
|
||||
inboundMutationLocksMu.Unlock()
|
||||
held.Unlock()
|
||||
}
|
||||
@@ -200,15 +200,24 @@ func (s *ClientService) resetAllClientTrafficsLocked(id int) error {
|
||||
}
|
||||
|
||||
func (s *ClientService) ResetAllTraffics() (bool, error) {
|
||||
db := database.GetDB()
|
||||
res := db.Model(&xray.ClientTraffic{}).
|
||||
Where("1 = 1").
|
||||
Updates(map[string]any{"up": 0, "down": 0})
|
||||
if res.Error != nil {
|
||||
return false, res.Error
|
||||
}
|
||||
if err := db.Where("1 = 1").Delete(&model.ClientGlobalTraffic{}).Error; err != nil {
|
||||
var affected int64
|
||||
err := submitTrafficWrite(func() error {
|
||||
return database.GetDB().Transaction(func(tx *gorm.DB) error {
|
||||
res := tx.Model(&xray.ClientTraffic{}).
|
||||
Where("1 = 1").
|
||||
Updates(map[string]any{"enable": true, "up": 0, "down": 0})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
affected = res.RowsAffected
|
||||
if err := tx.Where("1 = 1").Delete(&model.ClientGlobalTraffic{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Where("1 = 1").Delete(&model.NodeClientTraffic{}).Error
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return res.RowsAffected > 0, nil
|
||||
return affected > 0, nil
|
||||
}
|
||||
|
||||
@@ -33,6 +33,15 @@ func NewEmailService(settingService service.SettingService) *EmailService {
|
||||
return &EmailService{settingService: settingService}
|
||||
}
|
||||
|
||||
// smtpConnectTimeout bounds the TCP dial. smtpDeadline bounds every SMTP
|
||||
// protocol step after the connection is up, so a server that accepts the socket
|
||||
// but then stalls cannot block the sender goroutine (and leak its socket) long
|
||||
// after the caller's own timeout has already fired. smtpDeadline is a var only
|
||||
// so tests can shorten it.
|
||||
const smtpConnectTimeout = 10 * time.Second
|
||||
|
||||
var smtpDeadline = 30 * time.Second
|
||||
|
||||
// Send sends an HTML email to all configured recipients.
|
||||
func (s *EmailService) Send(subject, body string) error {
|
||||
host, err := s.settingService.GetSmtpHost()
|
||||
@@ -57,6 +66,7 @@ func (s *EmailService) Send(subject, body string) error {
|
||||
if from == "" {
|
||||
return fmt.Errorf("smtp from not configured")
|
||||
}
|
||||
from, fromName = resolveFrom(from, fromName)
|
||||
|
||||
recipients := parseRecipients(toStr)
|
||||
if len(recipients) == 0 {
|
||||
@@ -81,7 +91,7 @@ func (s *EmailService) Send(subject, body string) error {
|
||||
case "tls":
|
||||
ch <- result{s.sendWithTLS(addr, auth, from, recipients, msg, host)}
|
||||
case "starttls", "none":
|
||||
ch <- result{smtp.SendMail(addr, auth, from, recipients, msg)}
|
||||
ch <- result{s.sendPlain(addr, auth, from, recipients, msg, host)}
|
||||
default:
|
||||
ch <- result{fmt.Errorf("unknown SMTP encryption type: %s", encryptionType)}
|
||||
}
|
||||
@@ -116,6 +126,10 @@ func (s *EmailService) TestConnection() SMTPTestResult {
|
||||
if from == "" {
|
||||
from = username
|
||||
}
|
||||
if from == "" {
|
||||
return SMTPTestResult{false, "send", "smtpFromNotConfigured"}
|
||||
}
|
||||
from, fromName = resolveFrom(from, fromName)
|
||||
|
||||
recipients := parseRecipients(toStr)
|
||||
if len(recipients) == 0 {
|
||||
@@ -142,6 +156,7 @@ func (s *EmailService) TestConnection() SMTPTestResult {
|
||||
return SMTPTestResult{false, "connect", classifySMTPError(err)}
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(smtpDeadline))
|
||||
|
||||
// Stage 2: Handshake + Auth
|
||||
client, err := smtp.NewClient(conn, host)
|
||||
@@ -202,7 +217,7 @@ func (s *EmailService) TestConnection() SMTPTestResult {
|
||||
|
||||
func (s *EmailService) sendWithTLS(addr string, auth smtp.Auth, from string, to []string, msg []byte, host string) error {
|
||||
// Dial with explicit timeout
|
||||
dialer := &net.Dialer{Timeout: 10 * time.Second}
|
||||
dialer := &net.Dialer{Timeout: smtpConnectTimeout}
|
||||
conn, err := (&tls.Dialer{NetDialer: dialer, Config: &tls.Config{
|
||||
ServerName: host,
|
||||
InsecureSkipVerify: false,
|
||||
@@ -211,6 +226,7 @@ func (s *EmailService) sendWithTLS(addr string, auth smtp.Auth, from string, to
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(smtpDeadline))
|
||||
|
||||
client, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
@@ -244,6 +260,56 @@ func (s *EmailService) sendWithTLS(addr string, auth smtp.Auth, from string, to
|
||||
return w.Close()
|
||||
}
|
||||
|
||||
// sendPlain delivers over a plain TCP connection, opportunistically upgrading
|
||||
// via STARTTLS when the server advertises it (the behavior net/smtp.SendMail
|
||||
// gives the "starttls" and "none" transports). Unlike SendMail it dials with a
|
||||
// timeout and arms a connection deadline, so a server that never speaks or
|
||||
// stalls mid-protocol cannot block the sender goroutine past smtpDeadline.
|
||||
func (s *EmailService) sendPlain(addr string, auth smtp.Auth, from string, to []string, msg []byte, host string) error {
|
||||
conn, err := (&net.Dialer{Timeout: smtpConnectTimeout}).Dial("tcp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(smtpDeadline))
|
||||
|
||||
client, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if err = client.Hello("localhost"); err != nil {
|
||||
return err
|
||||
}
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
if err = client.StartTLS(&tls.Config{ServerName: host}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if auth != nil {
|
||||
if err = client.Auth(auth); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err = client.Mail(from); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range to {
|
||||
if err = client.Rcpt(r); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = w.Write(msg); err != nil {
|
||||
return err
|
||||
}
|
||||
return w.Close()
|
||||
}
|
||||
|
||||
// SendTest sends a test email and returns any error with detail.
|
||||
func (s *EmailService) SendTest() error {
|
||||
return s.Send(
|
||||
@@ -304,6 +370,17 @@ func parseRecipients(toStr string) []string {
|
||||
// (entity.AllSetting.CheckValid), this is defense in depth for buildMessage.
|
||||
var headerSanitizer = strings.NewReplacer("\r", "", "\n", "")
|
||||
|
||||
func resolveFrom(from, fromName string) (string, string) {
|
||||
parsed, err := mail.ParseAddress(from)
|
||||
if err != nil {
|
||||
return from, fromName
|
||||
}
|
||||
if fromName == "" {
|
||||
fromName = parsed.Name
|
||||
}
|
||||
return parsed.Address, fromName
|
||||
}
|
||||
|
||||
func buildMessage(fromAddr, fromName string, to []string, subject, body string) []byte {
|
||||
fromAddr = headerSanitizer.Replace(fromAddr)
|
||||
fromName = headerSanitizer.Replace(fromName)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSendPlainReturnsOnStalledServer(t *testing.T) {
|
||||
orig := smtpDeadline
|
||||
smtpDeadline = 300 * time.Millisecond
|
||||
t.Cleanup(func() { smtpDeadline = orig })
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
stall := make(chan struct{})
|
||||
defer close(stall)
|
||||
go func() {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
<-stall
|
||||
}()
|
||||
|
||||
s := &EmailService{}
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- s.sendPlain(ln.Addr().String(), nil, "from@example.com",
|
||||
[]string{"to@example.com"}, []byte("body"), "example.com")
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err == nil {
|
||||
t.Fatal("expected an error from a silent SMTP server, got nil")
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("sendPlain did not return on a stalled server within the deadline")
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,20 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net"
|
||||
"net/mail"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
func TestBuildMessageIsRFC5322(t *testing.T) {
|
||||
@@ -62,6 +71,140 @@ func TestBuildMessageFromWithoutName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func startFakeSMTPServer(t *testing.T) (string, func() []string) {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
|
||||
var mu sync.Mutex
|
||||
var lines []string
|
||||
record := func(line string) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
lines = append(lines, line)
|
||||
}
|
||||
|
||||
go func() {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
reader := bufio.NewReader(conn)
|
||||
fmt.Fprint(conn, "220 fake ready\r\n")
|
||||
inData := false
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
record(line)
|
||||
if inData {
|
||||
if line == "." {
|
||||
inData = false
|
||||
fmt.Fprint(conn, "250 ok\r\n")
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(line, "DATA"):
|
||||
inData = true
|
||||
fmt.Fprint(conn, "354 send\r\n")
|
||||
case strings.HasPrefix(line, "QUIT"):
|
||||
fmt.Fprint(conn, "221 bye\r\n")
|
||||
return
|
||||
default:
|
||||
fmt.Fprint(conn, "250 ok\r\n")
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return ln.Addr().String(), func() []string {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return append([]string(nil), lines...)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendUsesBareAddressFromNameAddrSmtpFrom(t *testing.T) {
|
||||
if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
|
||||
addr, recordedLines := startFakeSMTPServer(t)
|
||||
host, portStr, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
settingService := service.SettingService{}
|
||||
mustSet := func(name string, err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatalf("set %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
mustSet("host", settingService.SetSmtpHost(host))
|
||||
mustSet("port", settingService.SetSmtpPort(port))
|
||||
mustSet("from", settingService.SetSmtpFrom("3x-ui Panel <panel@example.com>"))
|
||||
mustSet("to", settingService.SetSmtpTo("admin@example.com"))
|
||||
mustSet("encryption", settingService.SetSmtpEncryptionType("none"))
|
||||
|
||||
if err := NewEmailService(settingService).Send("subject", "<b>hi</b>"); err != nil {
|
||||
t.Fatalf("send: %v", err)
|
||||
}
|
||||
|
||||
var mailFrom, fromHeader string
|
||||
for _, line := range recordedLines() {
|
||||
if strings.HasPrefix(line, "MAIL FROM:") {
|
||||
mailFrom = line
|
||||
}
|
||||
if strings.HasPrefix(line, "From: ") {
|
||||
fromHeader = line
|
||||
}
|
||||
}
|
||||
if want := "MAIL FROM:<panel@example.com>"; mailFrom != want {
|
||||
t.Errorf("envelope sender = %q, want %q", mailFrom, want)
|
||||
}
|
||||
if want := `From: "3x-ui Panel" <panel@example.com>`; fromHeader != want {
|
||||
t.Errorf("from header = %q, want %q", fromHeader, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectionReportsMissingFrom(t *testing.T) {
|
||||
if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
|
||||
settingService := service.SettingService{}
|
||||
mustSet := func(name string, err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatalf("set %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
mustSet("host", settingService.SetSmtpHost("127.0.0.1"))
|
||||
mustSet("port", settingService.SetSmtpPort(1))
|
||||
mustSet("to", settingService.SetSmtpTo("admin@example.com"))
|
||||
mustSet("encryption", settingService.SetSmtpEncryptionType("none"))
|
||||
|
||||
got := NewEmailService(settingService).TestConnection()
|
||||
want := SMTPTestResult{Success: false, Stage: "send", Message: "smtpFromNotConfigured"}
|
||||
if got != want {
|
||||
t.Errorf("TestConnection() = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMessageStripsHeaderInjection(t *testing.T) {
|
||||
raw := buildMessage(
|
||||
"panel@example.com\r\nBcc: evil@example.com",
|
||||
|
||||
@@ -719,6 +719,7 @@ func (s *InboundService) normalizeMtprotoXrayPort(inbound *model.Inbound, oldSet
|
||||
// then saves the inbound to the database and optionally adds it to the running Xray instance.
|
||||
// Returns the created inbound, whether Xray needs restart, and any error.
|
||||
func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
|
||||
inbound.Id = 0
|
||||
// Normalize streamSettings based on protocol
|
||||
s.normalizeStreamSettings(inbound)
|
||||
if err := validateFinalMaskRealityCombo(inbound.StreamSettings); err != nil {
|
||||
@@ -803,6 +804,10 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
|
||||
if client.Auth == "" {
|
||||
return inbound, false, common.NewError("empty client ID")
|
||||
}
|
||||
case "wireguard":
|
||||
if client.PublicKey == "" {
|
||||
return inbound, false, common.NewError("wireguard client requires a key")
|
||||
}
|
||||
case "mtproto":
|
||||
if client.Secret == "" {
|
||||
return inbound, false, common.NewError("mtproto client requires a secret")
|
||||
@@ -1098,6 +1103,10 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if mtprotoRoutesThroughXray(inbound) {
|
||||
needRestart = true
|
||||
}
|
||||
|
||||
if !push {
|
||||
return true, nil
|
||||
}
|
||||
@@ -1298,13 +1307,16 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||
pushable = false
|
||||
}
|
||||
}
|
||||
newProtocolIsMtproto := oldInbound.Protocol == model.MTProto
|
||||
if pushable {
|
||||
if err2 := rt.UpdateInbound(context.Background(), &oldSnapshot, payload); err2 == nil {
|
||||
logger.Debug("Updated inbound applied on", rt.Name(), ":", oldInbound.Tag)
|
||||
} else {
|
||||
logger.Debug("Unable to update inbound on", rt.Name(), ":", err2)
|
||||
if oldInbound.Protocol != model.MTProto {
|
||||
needRestart = true
|
||||
postCommitApply = func() {
|
||||
if err2 := rt.UpdateInbound(context.Background(), &oldSnapshot, payload); err2 == nil {
|
||||
logger.Debug("Updated inbound applied on", rt.Name(), ":", oldInbound.Tag)
|
||||
} else {
|
||||
logger.Debug("Unable to update inbound on", rt.Name(), ":", err2)
|
||||
if !newProtocolIsMtproto {
|
||||
needRestart = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,64 @@ func TestAddInbound_RejectsFinalMaskRealityCombo(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddInbound_IgnoresBoundIdAndCreatesNewRow(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
svc := &InboundService{}
|
||||
|
||||
first := &model.Inbound{Tag: "in-45100-tcp", Enable: true, Listen: "0.0.0.0", Port: 45100, Protocol: model.VLESS, Settings: `{"clients":[]}`}
|
||||
created, _, err := svc.AddInbound(first)
|
||||
if err != nil {
|
||||
t.Fatalf("AddInbound first: %v", err)
|
||||
}
|
||||
|
||||
second := &model.Inbound{Id: created.Id, Tag: "in-45101-tcp", Enable: true, Listen: "0.0.0.0", Port: 45101, Protocol: model.VLESS, Settings: `{"clients":[]}`}
|
||||
if _, _, err := svc.AddInbound(second); err != nil {
|
||||
t.Fatalf("AddInbound second: %v", err)
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := database.GetDB().Model(&model.Inbound{}).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("expected 2 inbound rows, got %d: a bound id overwrote the first row instead of creating a new one", count)
|
||||
}
|
||||
|
||||
var reloaded model.Inbound
|
||||
if err := database.GetDB().First(&reloaded, created.Id).Error; err != nil {
|
||||
t.Fatalf("reload first: %v", err)
|
||||
}
|
||||
if reloaded.Port != 45100 {
|
||||
t.Fatalf("first inbound port = %d, want 45100 (the second add overwrote it)", reloaded.Port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddInbound_AcceptsWireguardClientWithKey(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
svc := &InboundService{}
|
||||
|
||||
settings := `{"secretKey":"` + wgTestSecretKey() + `","mtu":1420,"clients":[{"email":"wgimp@x","enable":true,"privateKey":"keep-priv","publicKey":"keep-pub","allowedIPs":["10.0.0.50/32"]}]}`
|
||||
in := &model.Inbound{
|
||||
Tag: "in-45200-wg",
|
||||
Enable: true,
|
||||
Listen: "0.0.0.0",
|
||||
Port: 45200,
|
||||
Protocol: model.WireGuard,
|
||||
Settings: settings,
|
||||
}
|
||||
if _, _, err := svc.AddInbound(in); err != nil {
|
||||
t.Fatalf("AddInbound rejected a keyed WireGuard client: %v", err)
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := database.GetDB().Model(&model.Inbound{}).Where("tag = ?", "in-45200-wg").Count(&count).Error; err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("WireGuard inbound with a keyed client was not created, row count = %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// end-to-end: same guard on the update path, on a row that was valid before
|
||||
// the edit — the rejected StreamSettings must not overwrite the stored row.
|
||||
func TestUpdateInbound_RejectsFinalMaskRealityCombo(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
||||
)
|
||||
|
||||
func TestUpdateInboundLocalMtprotoDefersPushUntilCommit(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
|
||||
mgr := runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }})
|
||||
fake := &fakeNodeRuntime{}
|
||||
mgr.SetLocalRuntimeOverride(fake)
|
||||
runtime.SetManager(mgr)
|
||||
t.Cleanup(func() { runtime.SetManager(nil) })
|
||||
|
||||
seedInboundConflict(t, "mt-txfail", "", 46150, model.MTProto, "",
|
||||
`{"clients":[{"email":"mtx","secret":"`+mtprotoTestSecretA+`","enable":true}]}`)
|
||||
seeded := loadInboundByTag(t, "mt-txfail")
|
||||
seedClientTraffic(t, seeded.Id, "mtx", true)
|
||||
|
||||
db := database.GetDB()
|
||||
const cbName = "b1-05:fail-inbound-update"
|
||||
if err := db.Callback().Update().After("gorm:update").Register(cbName, func(tx *gorm.DB) {
|
||||
if tx.Statement != nil && tx.Statement.Table == "inbounds" {
|
||||
tx.AddError(errors.New("injected transaction failure"))
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatalf("register callback: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Callback().Update().Remove(cbName) })
|
||||
|
||||
update := *loadInboundByTag(t, "mt-txfail")
|
||||
update.Remark = "edited"
|
||||
if _, _, err := (&InboundService{}).UpdateInbound(&update); err == nil {
|
||||
t.Fatal("UpdateInbound: expected the injected transaction failure")
|
||||
}
|
||||
|
||||
if n := fake.updateInbound.Load(); n != 0 {
|
||||
t.Fatalf("the MTProto sidecar push ran %d time(s) inside the failed transaction; it must be deferred until the commit succeeds", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetInboundEnableRoutedMtprotoRequestsRestart(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
|
||||
mgr := runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }})
|
||||
mgr.SetLocalRuntimeOverride(&fakeNodeRuntime{})
|
||||
runtime.SetManager(mgr)
|
||||
t.Cleanup(func() { runtime.SetManager(nil) })
|
||||
|
||||
seedInboundConflict(t, "mt-route", "", 46160, model.MTProto, "",
|
||||
`{"clients":[{"email":"mtr","secret":"`+mtprotoTestSecretA+`","enable":true}],"routeThroughXray":true,"routeXrayPort":12345}`)
|
||||
seeded := loadInboundByTag(t, "mt-route")
|
||||
if err := database.GetDB().Model(&model.Inbound{}).Where("id = ?", seeded.Id).Update("enable", false).Error; err != nil {
|
||||
t.Fatalf("force disable: %v", err)
|
||||
}
|
||||
|
||||
needRestart, err := (&InboundService{}).SetInboundEnable(seeded.Id, true)
|
||||
if err != nil {
|
||||
t.Fatalf("SetInboundEnable: %v", err)
|
||||
}
|
||||
if !needRestart {
|
||||
t.Fatal("re-enabling a routed MTProto inbound must request an xray restart to re-inject the egress bridge")
|
||||
}
|
||||
}
|
||||
@@ -37,9 +37,11 @@ func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clien
|
||||
|
||||
defer func() {
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
} else {
|
||||
tx.Commit()
|
||||
if rbErr := tx.Rollback().Error; rbErr != nil {
|
||||
logger.Warning("Error rolling back traffic tx:", rbErr)
|
||||
}
|
||||
} else if cErr := tx.Commit().Error; cErr != nil {
|
||||
logger.Warning("Error committing traffic tx:", cErr)
|
||||
}
|
||||
}()
|
||||
err = s.addInboundTraffic(tx, inboundTraffics)
|
||||
@@ -51,25 +53,25 @@ func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clien
|
||||
return false, false, err
|
||||
}
|
||||
|
||||
needRestart0, count, err := s.autoRenewClients(tx)
|
||||
if err != nil {
|
||||
logger.Warning("Error in renew clients:", err)
|
||||
needRestart0, count, renewErr := s.autoRenewClients(tx)
|
||||
if renewErr != nil {
|
||||
logger.Warning("Error in renew clients:", renewErr)
|
||||
} else if count > 0 {
|
||||
logger.Debugf("%v clients renewed", count)
|
||||
}
|
||||
|
||||
disabledClientsCount := int64(0)
|
||||
needRestart1, count, err := s.disableInvalidClients(tx)
|
||||
if err != nil {
|
||||
logger.Warning("Error in disabling invalid clients:", err)
|
||||
needRestart1, count, disableClientsErr := s.disableInvalidClients(tx)
|
||||
if disableClientsErr != nil {
|
||||
logger.Warning("Error in disabling invalid clients:", disableClientsErr)
|
||||
} else if count > 0 {
|
||||
logger.Debugf("%v clients disabled", count)
|
||||
disabledClientsCount = count
|
||||
}
|
||||
|
||||
needRestart2, count, err := s.disableInvalidInbounds(tx)
|
||||
if err != nil {
|
||||
logger.Warning("Error in disabling invalid inbounds:", err)
|
||||
needRestart2, count, disableInboundsErr := s.disableInvalidInbounds(tx)
|
||||
if disableInboundsErr != nil {
|
||||
logger.Warning("Error in disabling invalid inbounds:", disableInboundsErr)
|
||||
} else if count > 0 {
|
||||
logger.Debugf("%v inbounds disabled", count)
|
||||
}
|
||||
@@ -657,6 +659,7 @@ func (s *InboundService) ResetAllTraffics() error {
|
||||
return s.resetAllTrafficsLocked()
|
||||
})
|
||||
if err == nil {
|
||||
s.propagateResetAllTrafficsToNodes()
|
||||
s.resetAllMtprotoQuotas()
|
||||
}
|
||||
return err
|
||||
@@ -666,52 +669,53 @@ func (s *InboundService) resetAllTrafficsLocked() error {
|
||||
db := database.GetDB()
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := db.Model(model.Inbound{}).
|
||||
return db.Model(model.Inbound{}).
|
||||
Where("user_id > ?", 0).
|
||||
Updates(map[string]any{
|
||||
"up": 0,
|
||||
"down": 0,
|
||||
"last_traffic_reset_time": now,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}).Error
|
||||
}
|
||||
|
||||
// propagateResetAllTrafficsToNodes tells every node to zero its own counters.
|
||||
// Kept OUT of the traffic-writer transaction: each remote call can block up to
|
||||
// remoteHTTPTimeout, and holding the single serial writer across N such calls
|
||||
// stalls traffic accounting and drops the deltas of every concurrent poll.
|
||||
func (s *InboundService) propagateResetAllTrafficsToNodes() {
|
||||
nodes, err := (&NodeService{}).GetAll()
|
||||
if err == nil {
|
||||
for _, node := range nodes {
|
||||
if rt, err := runtime.GetManager().RuntimeFor(&node.Id); err == nil {
|
||||
if e := rt.ResetAllTraffics(context.Background()); e != nil {
|
||||
logger.Warning("ResetAllTraffics: remote propagation to", rt.Name(), "failed:", e)
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, node := range nodes {
|
||||
if rt, err := runtime.GetManager().RuntimeFor(&node.Id); err == nil {
|
||||
if e := rt.ResetAllTraffics(context.Background()); e != nil {
|
||||
logger.Warning("ResetAllTraffics: remote propagation to", rt.Name(), "failed:", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *InboundService) ResetInboundTraffic(id int) error {
|
||||
return submitTrafficWrite(func() error {
|
||||
db := database.GetDB()
|
||||
if err := db.Model(model.Inbound{}).
|
||||
if err := submitTrafficWrite(func() error {
|
||||
return database.GetDB().Model(model.Inbound{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]any{"up": 0, "down": 0}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
Updates(map[string]any{"up": 0, "down": 0}).Error
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
inbound, err := s.GetInbound(id)
|
||||
if err == nil && inbound != nil && inbound.NodeID != nil {
|
||||
if rt, rterr := s.runtimeFor(inbound); rterr == nil {
|
||||
if e := rt.ResetInboundTraffic(context.Background(), inbound); e != nil {
|
||||
logger.Warning("ResetInboundTraffic: remote propagation to", rt.Name(), "failed:", e)
|
||||
}
|
||||
} else {
|
||||
logger.Warning("ResetInboundTraffic: runtime lookup failed:", rterr)
|
||||
inbound, err := s.GetInbound(id)
|
||||
if err == nil && inbound != nil && inbound.NodeID != nil {
|
||||
if rt, rterr := s.runtimeFor(inbound); rterr == nil {
|
||||
if e := rt.ResetInboundTraffic(context.Background(), inbound); e != nil {
|
||||
logger.Warning("ResetInboundTraffic: remote propagation to", rt.Name(), "failed:", e)
|
||||
}
|
||||
} else {
|
||||
logger.Warning("ResetInboundTraffic: runtime lookup failed:", rterr)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *InboundService) DelDepletedClients(id int) (err error) {
|
||||
|
||||
@@ -238,7 +238,7 @@ func (s *WarpService) doWarpRequest(req *http.Request) ([]byte, error) {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
)
|
||||
|
||||
func TestDoWarpRequestCapsResponseBody(t *testing.T) {
|
||||
if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
|
||||
oversize := maxResponseSize + 4096
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(bytes.Repeat([]byte("a"), oversize))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
s := &WarpService{}
|
||||
req, err := http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest: %v", err)
|
||||
}
|
||||
|
||||
body, err := s.doWarpRequest(req)
|
||||
if err != nil {
|
||||
t.Fatalf("doWarpRequest: %v", err)
|
||||
}
|
||||
if len(body) != maxResponseSize {
|
||||
t.Fatalf("response body not capped: got %d bytes, want %d", len(body), maxResponseSize)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseXrayKeyPairOutput(t *testing.T) {
|
||||
a, b, err := parseXrayKeyPairOutput("Private key: abc123\nPublic key: def456\n")
|
||||
if err != nil {
|
||||
t.Fatalf("well-formed output errored: %v", err)
|
||||
}
|
||||
if a != "abc123" || b != "def456" {
|
||||
t.Fatalf("got (%q, %q), want (abc123, def456)", a, b)
|
||||
}
|
||||
|
||||
malformed := []string{
|
||||
"",
|
||||
"only one line: value",
|
||||
"Private key: abc\n",
|
||||
"no colon here\nno colon two",
|
||||
"Private key\nPublic key",
|
||||
}
|
||||
for _, out := range malformed {
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("parseXrayKeyPairOutput panicked on %q: %v", out, r)
|
||||
}
|
||||
}()
|
||||
if _, _, err := parseXrayKeyPairOutput(out); err == nil {
|
||||
t.Errorf("expected error for malformed output %q, got nil", out)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
@@ -533,9 +533,17 @@ func FilterNodeSnapshot(n *model.Node, snap *runtime.TrafficSnapshot) {
|
||||
if n == nil || snap == nil || n.InboundSyncMode != "selected" {
|
||||
return
|
||||
}
|
||||
allowed := make(map[string]struct{}, len(n.InboundTags))
|
||||
prefix := nodeTagPrefix(&n.Id)
|
||||
allowed := make(map[string]struct{}, len(n.InboundTags)*2)
|
||||
for _, tag := range n.InboundTags {
|
||||
allowed[tag] = struct{}{}
|
||||
if prefix != "" {
|
||||
if stripped, found := strings.CutPrefix(tag, prefix); found {
|
||||
allowed[stripped] = struct{}{}
|
||||
} else {
|
||||
allowed[prefix+tag] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
filtered := make([]*model.Inbound, 0, len(snap.Inbounds))
|
||||
for _, inbound := range snap.Inbounds {
|
||||
|
||||
@@ -212,3 +212,26 @@ func TestFilterNodeSnapshot(t *testing.T) {
|
||||
t.Fatalf("empty selection kept %d inbounds, want 0", len(none.Inbounds))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterNodeSnapshotMatchesPrefixedSelectedTag(t *testing.T) {
|
||||
snap := &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{
|
||||
{Tag: "in-100-tcp"},
|
||||
{Tag: "in-443-tcp"},
|
||||
}}
|
||||
FilterNodeSnapshot(&model.Node{
|
||||
Id: 5,
|
||||
InboundSyncMode: "selected",
|
||||
InboundTags: []string{"in-100-tcp", "n5-in-443-tcp"},
|
||||
}, snap)
|
||||
|
||||
kept := make(map[string]bool, len(snap.Inbounds))
|
||||
for _, ib := range snap.Inbounds {
|
||||
kept[ib.Tag] = true
|
||||
}
|
||||
if !kept["in-443-tcp"] {
|
||||
t.Fatalf("node-side tag in-443-tcp filtered out despite the prefixed central tag being selected; kept=%v", kept)
|
||||
}
|
||||
if !kept["in-100-tcp"] {
|
||||
t.Fatalf("bare selected tag in-100-tcp was dropped; kept=%v", kept)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/link"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
@@ -276,6 +277,23 @@ func (s *OutboundSubscriptionService) RefreshAllEnabled() (int, error) {
|
||||
return refreshed, nil
|
||||
}
|
||||
|
||||
// subscriptionFetchClient builds the HTTP client used to fetch a subscription.
|
||||
// A configured panel egress proxy dials the loopback SOCKS bridge (xray handles
|
||||
// the real egress), so its localhost dial must not be SSRF-blocked. A direct
|
||||
// fetch dials the target itself and re-resolves the hostname at dial time, so it
|
||||
// goes through the SSRF-guarded dialer, which resolves, checks and dials the same
|
||||
// IP atomically — closing the DNS-rebinding gap left by validating the hostname
|
||||
// separately from the dial.
|
||||
func (s *OutboundSubscriptionService) subscriptionFetchClient(timeout time.Duration) *http.Client {
|
||||
if s.settingService.PanelEgressProxyURL() != "" {
|
||||
return s.settingService.NewProxiedHTTPClient(timeout)
|
||||
}
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{DialContext: netsafe.SSRFGuardedDialContext},
|
||||
}
|
||||
}
|
||||
|
||||
// fetchAndStore does the actual network + parse + stability + persist work.
|
||||
func (s *OutboundSubscriptionService) fetchAndStore(sub *model.OutboundSubscription) ([]any, error) {
|
||||
// Re-sanitize on every fetch (handles legacy rows + defense in depth against
|
||||
@@ -291,7 +309,7 @@ func (s *OutboundSubscriptionService) fetchAndStore(sub *model.OutboundSubscript
|
||||
}
|
||||
sub.Url = cleanURL // persist the cleaned version
|
||||
|
||||
client := s.settingService.NewProxiedHTTPClient(30 * time.Second)
|
||||
client := s.subscriptionFetchClient(30 * time.Second)
|
||||
// Re-validate every redirect hop: the initial host is checked above, but a
|
||||
// redirect could still point at a private/internal address (SSRF). Cap the
|
||||
// redirect chain as well.
|
||||
@@ -307,7 +325,8 @@ func (s *OutboundSubscriptionService) fetchAndStore(sub *model.OutboundSubscript
|
||||
return rejectPrivateHost(ctx, req.URL.Hostname())
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, sub.Url, nil)
|
||||
reqCtx := netsafe.ContextWithAllowPrivate(context.Background(), sub.AllowPrivate)
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, sub.Url, nil)
|
||||
if err != nil {
|
||||
s.recordError(sub, err)
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
|
||||
)
|
||||
|
||||
func TestSubscriptionFetchClientBlocksPrivateDial(t *testing.T) {
|
||||
setupSettingTestDB(t)
|
||||
client := (&OutboundSubscriptionService{}).subscriptionFetchClient(5 * time.Second)
|
||||
|
||||
ctx := netsafe.ContextWithAllowPrivate(context.Background(), false)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://127.0.0.1:1/", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
|
||||
_, err = client.Do(req)
|
||||
if err == nil {
|
||||
t.Fatal("the fetch client dialed a private address instead of blocking it")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "blocked private") {
|
||||
t.Fatalf("expected an SSRF-guard block, got a plain dial error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
||||
)
|
||||
|
||||
type blockingResetRuntime struct {
|
||||
fakeNodeRuntime
|
||||
reached chan struct{}
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func (b *blockingResetRuntime) ResetAllTraffics(context.Context) error {
|
||||
close(b.reached)
|
||||
<-b.release
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestResetAllTrafficsDoesNotBlockWriterOnNodeCall(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
resetTrafficWriterForTest(t)
|
||||
StartTrafficWriter()
|
||||
|
||||
mgr := runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }})
|
||||
runtime.SetManager(mgr)
|
||||
t.Cleanup(func() { runtime.SetManager(nil) })
|
||||
|
||||
node := &model.Node{Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, Status: "online"}
|
||||
if err := db.Create(node).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
fake := &blockingResetRuntime{reached: make(chan struct{}), release: make(chan struct{})}
|
||||
mgr.SetRuntimeOverride(node.Id, fake)
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- (&InboundService{}).ResetAllTraffics() }()
|
||||
|
||||
select {
|
||||
case <-fake.reached:
|
||||
case <-time.After(3 * time.Second):
|
||||
close(fake.release)
|
||||
t.Fatal("node ResetAllTraffics was never reached")
|
||||
}
|
||||
|
||||
writerFree := make(chan error, 1)
|
||||
go func() { writerFree <- submitTrafficWrite(func() error { return nil }) }()
|
||||
select {
|
||||
case err := <-writerFree:
|
||||
if err != nil {
|
||||
close(fake.release)
|
||||
t.Fatalf("concurrent writer submit failed: %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
close(fake.release)
|
||||
<-done
|
||||
t.Fatal("the serial traffic writer was blocked by a node reset HTTP call")
|
||||
}
|
||||
|
||||
close(fake.release)
|
||||
if err := <-done; err != nil {
|
||||
t.Fatalf("ResetAllTraffics: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1132,6 +1132,40 @@ func (s *ServerService) GetLogs(count string, level string, syslog string) []str
|
||||
return lines
|
||||
}
|
||||
|
||||
// parseAccessLogFields extracts the structured fields from one Xray access-log
|
||||
// line. Lines are attacker-influenced (a client's requested destination lands in
|
||||
// the log verbatim) and may be truncated, so every positional lookup is length
|
||||
// guarded: a malformed line yields a partial entry rather than panicking.
|
||||
func parseAccessLogFields(line string) LogEntry {
|
||||
var entry LogEntry
|
||||
parts := strings.Fields(line)
|
||||
|
||||
for i, part := range parts {
|
||||
|
||||
if i == 0 && len(parts) > 1 {
|
||||
dateTime, err := time.ParseInLocation("2006/01/02 15:04:05.999999", parts[0]+" "+parts[1], time.Local)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
entry.DateTime = dateTime.UTC()
|
||||
}
|
||||
|
||||
if part == "from" && i+1 < len(parts) {
|
||||
entry.FromAddress = strings.TrimLeft(parts[i+1], "/")
|
||||
} else if part == "accepted" && i+1 < len(parts) {
|
||||
entry.ToAddress = strings.TrimLeft(parts[i+1], "/")
|
||||
} else if strings.HasPrefix(part, "[") {
|
||||
entry.Inbound = part[1:]
|
||||
} else if strings.HasSuffix(part, "]") {
|
||||
entry.Outbound = part[:len(part)-1]
|
||||
} else if part == "email:" && i+1 < len(parts) {
|
||||
entry.Email = parts[i+1]
|
||||
}
|
||||
}
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
func (s *ServerService) GetXrayLogs(
|
||||
count string,
|
||||
filter string,
|
||||
@@ -1176,31 +1210,7 @@ func (s *ServerService) GetXrayLogs(
|
||||
continue
|
||||
}
|
||||
|
||||
var entry LogEntry
|
||||
parts := strings.Fields(line)
|
||||
|
||||
for i, part := range parts {
|
||||
|
||||
if i == 0 {
|
||||
dateTime, err := time.ParseInLocation("2006/01/02 15:04:05.999999", parts[0]+" "+parts[1], time.Local)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
entry.DateTime = dateTime.UTC()
|
||||
}
|
||||
|
||||
if part == "from" {
|
||||
entry.FromAddress = strings.TrimLeft(parts[i+1], "/")
|
||||
} else if part == "accepted" {
|
||||
entry.ToAddress = strings.TrimLeft(parts[i+1], "/")
|
||||
} else if strings.HasPrefix(part, "[") {
|
||||
entry.Inbound = part[1:]
|
||||
} else if strings.HasSuffix(part, "]") {
|
||||
entry.Outbound = part[:len(part)-1]
|
||||
} else if part == "email:" {
|
||||
entry.Email = parts[i+1]
|
||||
}
|
||||
}
|
||||
entry := parseAccessLogFields(line)
|
||||
|
||||
if logEntryContains(line, freedoms) {
|
||||
if showDirect == "false" {
|
||||
@@ -2013,6 +2023,24 @@ func (s *ServerService) UpdateGeofile(fileName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseXrayKeyPairOutput reads the two-line "Label: value" output that xray's
|
||||
// key-generation subcommands (x25519, mldsa65, mlkem768) print and returns the
|
||||
// two values. Short or label-less output yields an error instead of panicking
|
||||
// on an out-of-range slice index, so a future xray version that changes the
|
||||
// format degrades to a 500 with a message rather than a crash.
|
||||
func parseXrayKeyPairOutput(output string) (string, string, error) {
|
||||
lines := strings.Split(output, "\n")
|
||||
if len(lines) < 2 {
|
||||
return "", "", common.NewError("unexpected key generator output")
|
||||
}
|
||||
first := strings.Split(lines[0], ":")
|
||||
second := strings.Split(lines[1], ":")
|
||||
if len(first) < 2 || len(second) < 2 {
|
||||
return "", "", common.NewError("unexpected key generator output")
|
||||
}
|
||||
return strings.TrimSpace(first[1]), strings.TrimSpace(second[1]), nil
|
||||
}
|
||||
|
||||
func (s *ServerService) GetNewX25519Cert() (any, error) {
|
||||
// Run the command
|
||||
cmd := exec.CommandContext(context.Background(), xray.GetBinaryPath(), "x25519")
|
||||
@@ -2023,13 +2051,10 @@ func (s *ServerService) GetNewX25519Cert() (any, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lines := strings.Split(out.String(), "\n")
|
||||
|
||||
privateKeyLine := strings.Split(lines[0], ":")
|
||||
publicKeyLine := strings.Split(lines[1], ":")
|
||||
|
||||
privateKey := strings.TrimSpace(privateKeyLine[1])
|
||||
publicKey := strings.TrimSpace(publicKeyLine[1])
|
||||
privateKey, publicKey, err := parseXrayKeyPairOutput(out.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
keyPair := map[string]any{
|
||||
"privateKey": privateKey,
|
||||
@@ -2049,13 +2074,10 @@ func (s *ServerService) GetNewmldsa65() (any, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lines := strings.Split(out.String(), "\n")
|
||||
|
||||
SeedLine := strings.Split(lines[0], ":")
|
||||
VerifyLine := strings.Split(lines[1], ":")
|
||||
|
||||
seed := strings.TrimSpace(SeedLine[1])
|
||||
verify := strings.TrimSpace(VerifyLine[1])
|
||||
seed, verify, err := parseXrayKeyPairOutput(out.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
keyPair := map[string]any{
|
||||
"seed": seed,
|
||||
@@ -2371,13 +2393,10 @@ func (s *ServerService) GetNewmlkem768() (any, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lines := strings.Split(out.String(), "\n")
|
||||
|
||||
SeedLine := strings.Split(lines[0], ":")
|
||||
ClientLine := strings.Split(lines[1], ":")
|
||||
|
||||
seed := strings.TrimSpace(SeedLine[1])
|
||||
client := strings.TrimSpace(ClientLine[1])
|
||||
seed, client, err := parseXrayKeyPairOutput(out.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
keyPair := map[string]any{
|
||||
"seed": seed,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
)
|
||||
|
||||
func TestAnswerCallbackDeniesPrivilegedActionToNonAdmin(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("a non-admin callback reached a privileged handler: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
tg := &Tgbot{}
|
||||
for _, data := range []string{"get_backup", "reset_all_traffics_c", "add_client", "onlines", "inbounds"} {
|
||||
q := &telego.CallbackQuery{
|
||||
Data: data,
|
||||
From: telego.User{ID: 999999},
|
||||
Message: &telego.Message{Chat: telego.Chat{ID: 1}},
|
||||
}
|
||||
tg.answerCallback(q, false)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsClientSelfCallback(t *testing.T) {
|
||||
allowed := []string{"client_traffic", "client_sub_links", "client_qr_links", "client_sub_links alice@x"}
|
||||
for _, d := range allowed {
|
||||
if !isClientSelfCallback(d) {
|
||||
t.Errorf("%q should be a per-user client callback", d)
|
||||
}
|
||||
}
|
||||
denied := []string{"get_backup", "reset_all_traffics_c", "add_client", "onlines", "get_banlogs", "get_usage"}
|
||||
for _, d := range denied {
|
||||
if isClientSelfCallback(d) {
|
||||
t.Errorf("%q is an admin-only callback and must not be treated as per-user", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package tgbot
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDeleteMessageAfterDelayKeepsUserState(t *testing.T) {
|
||||
userStateMgr.reset()
|
||||
t.Cleanup(userStateMgr.reset)
|
||||
|
||||
const chatID = int64(4242)
|
||||
userStateMgr.set(chatID, "awaiting_comment")
|
||||
|
||||
tg := &Tgbot{}
|
||||
tg.deleteMessageAfterDelay(chatID, 1, 0)
|
||||
|
||||
if st, ok := userStateMgr.get(chatID); !ok || st != "awaiting_comment" {
|
||||
t.Fatalf("delayed message deletion cleared the conversation state: got (%q, %v), want (%q, true)", st, ok, "awaiting_comment")
|
||||
}
|
||||
}
|
||||
@@ -1128,6 +1128,10 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
|
||||
}
|
||||
}
|
||||
|
||||
if !isAdmin && !isClientSelfCallback(callbackQuery.Data) {
|
||||
return
|
||||
}
|
||||
|
||||
switch callbackQuery.Data {
|
||||
case "get_usage":
|
||||
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.buttons.serverUsage"))
|
||||
@@ -1526,3 +1530,17 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
|
||||
func checkAdmin(tgId int64) bool {
|
||||
return slices.Contains(adminIds, tgId)
|
||||
}
|
||||
|
||||
// isClientSelfCallback reports whether a callback is one of the per-user client
|
||||
// actions that resolve their own data from the caller's Telegram id, and so are
|
||||
// safe to run for a non-admin. Every other callback is admin-only (default-deny).
|
||||
func isClientSelfCallback(data string) bool {
|
||||
switch data {
|
||||
case "client_traffic", "client_commands", "client_sub_links",
|
||||
"client_individual_links", "client_qr_links":
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(data, "client_sub_links ") ||
|
||||
strings.HasPrefix(data, "client_individual_links ") ||
|
||||
strings.HasPrefix(data, "client_qr_links ")
|
||||
}
|
||||
|
||||
@@ -228,16 +228,25 @@ func (t *Tgbot) SendMsgToTgbotDeleteAfter(chatId int64, msg string, delayInSecon
|
||||
return
|
||||
}
|
||||
|
||||
// Delete the sent message after the specified number of seconds
|
||||
go func() {
|
||||
time.Sleep(time.Duration(delayInSeconds) * time.Second) // Wait for the specified delay
|
||||
t.deleteMessageTgBot(chatId, sentMsg.MessageID) // Delete the message
|
||||
userStateMgr.clear(chatId)
|
||||
}()
|
||||
// Delete the sent message after the specified number of seconds.
|
||||
go t.deleteMessageAfterDelay(chatId, sentMsg.MessageID, delayInSeconds)
|
||||
}
|
||||
|
||||
// deleteMessageAfterDelay waits delayInSeconds and then removes the message. It
|
||||
// deliberately does not touch the conversation state: every caller that ends a
|
||||
// wizard step already clears the state synchronously, and clearing it here — up
|
||||
// to several seconds later — would wipe a state the user set for the next step
|
||||
// in the meantime, silently dropping their following input.
|
||||
func (t *Tgbot) deleteMessageAfterDelay(chatId int64, messageID, delayInSeconds int) {
|
||||
time.Sleep(time.Duration(delayInSeconds) * time.Second)
|
||||
t.deleteMessageTgBot(chatId, messageID)
|
||||
}
|
||||
|
||||
// deleteMessageTgBot deletes a message from the chat.
|
||||
func (t *Tgbot) deleteMessageTgBot(chatId int64, messageID int) {
|
||||
if bot == nil {
|
||||
return
|
||||
}
|
||||
params := telego.DeleteMessageParams{
|
||||
ChatID: tu.ID(chatId),
|
||||
MessageID: messageID,
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
func TestAddTrafficCommitsDespiteDisableHelperError(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
svc := &InboundService{}
|
||||
|
||||
normal := &model.Inbound{UserId: 1, Tag: "in-normal", Enable: true, Port: 43001, Protocol: model.VLESS, Settings: `{"clients":[]}`}
|
||||
if err := db.Create(normal).Error; err != nil {
|
||||
t.Fatalf("seed normal inbound: %v", err)
|
||||
}
|
||||
expired := &model.Inbound{UserId: 1, Tag: "in-expired", Enable: true, Port: 43002, Protocol: model.VLESS, ExpiryTime: 1, Settings: `{"clients":[]}`}
|
||||
if err := db.Create(expired).Error; err != nil {
|
||||
t.Fatalf("seed expired inbound: %v", err)
|
||||
}
|
||||
|
||||
const cbName = "b2-03:fail-disable"
|
||||
if err := db.Callback().Update().After("gorm:update").Register(cbName, func(tx *gorm.DB) {
|
||||
if tx.Statement != nil && tx.Statement.Table == "inbounds" &&
|
||||
strings.Contains(tx.Statement.SQL.String(), "expiry_time") {
|
||||
tx.AddError(errors.New("injected disableInvalidInbounds failure"))
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatalf("register callback: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Callback().Update().Remove(cbName) })
|
||||
|
||||
if _, _, err := svc.AddTraffic([]*xray.Traffic{{IsInbound: true, Tag: "in-normal", Up: 500, Down: 700}}, nil); err != nil {
|
||||
t.Fatalf("AddTraffic: %v", err)
|
||||
}
|
||||
|
||||
var reloaded model.Inbound
|
||||
if err := db.Where("tag = ?", "in-normal").First(&reloaded).Error; err != nil {
|
||||
t.Fatalf("reload normal inbound: %v", err)
|
||||
}
|
||||
if reloaded.Up != 500 || reloaded.Down != 700 {
|
||||
t.Fatalf("traffic tick was rolled back by a best-effort disable-helper error: up=%d down=%d, want 500/700", reloaded.Up, reloaded.Down)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetAllTrafficsReenablesDepletedClients(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
svc := &ClientService{}
|
||||
|
||||
if err := db.Create(&xray.ClientTraffic{InboundId: 1, Email: "spent@x", Enable: false, Up: 60, Down: 60, Total: 100}).Error; err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
if _, err := svc.ResetAllTraffics(); err != nil {
|
||||
t.Fatalf("ResetAllTraffics: %v", err)
|
||||
}
|
||||
|
||||
row := readTraffic(t, db, "spent@x")
|
||||
if row.Up != 0 || row.Down != 0 {
|
||||
t.Fatalf("usage not reset: up=%d down=%d", row.Up, row.Down)
|
||||
}
|
||||
if !row.Enable {
|
||||
t.Fatal("a depleted client must be re-enabled after Reset All Client Traffic, matching every other reset path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetAllTrafficsClearsNodeBaselines(t *testing.T) {
|
||||
db := initTrafficTestDB(t)
|
||||
svc := &ClientService{}
|
||||
|
||||
if err := db.Create(&xray.ClientTraffic{InboundId: 1, Email: "spent@x", Enable: true, Up: 60, Down: 60, Total: 100}).Error; err != nil {
|
||||
t.Fatalf("seed traffic: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.NodeClientTraffic{NodeId: 1, Email: "spent@x", Up: 60, Down: 60}).Error; err != nil {
|
||||
t.Fatalf("seed node baseline: %v", err)
|
||||
}
|
||||
|
||||
if _, err := svc.ResetAllTraffics(); err != nil {
|
||||
t.Fatalf("ResetAllTraffics: %v", err)
|
||||
}
|
||||
|
||||
var cnt int64
|
||||
if err := db.Model(&model.NodeClientTraffic{}).Where("email = ?", "spent@x").Count(&cnt).Error; err != nil {
|
||||
t.Fatalf("count baselines: %v", err)
|
||||
}
|
||||
if cnt != 0 {
|
||||
t.Fatalf("Reset All Client Traffic must clear node baselines like its sibling reset paths, found %d", cnt)
|
||||
}
|
||||
}
|
||||
@@ -986,6 +986,9 @@ func (s *XrayService) RestartXray(isForce bool) error {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
logger.Debug("restart Xray, force:", isForce)
|
||||
if !isForce && isManuallyStopped.Load() {
|
||||
return nil
|
||||
}
|
||||
isManuallyStopped.Store(false)
|
||||
|
||||
xrayConfig, err := s.GetXrayConfig()
|
||||
@@ -1182,6 +1185,20 @@ func (s *XrayService) IsNeedRestartAndSetFalse() bool {
|
||||
return isNeedXrayRestart.CompareAndSwap(true, false)
|
||||
}
|
||||
|
||||
// ApplyPendingRestart consumes the need-restart flag and restarts Xray. If the
|
||||
// restart fails (for example GetXrayConfig hits a transient DB error and leaves
|
||||
// the old process running), it re-arms the flag so the next tick retries instead
|
||||
// of silently dropping the pending config change.
|
||||
func (s *XrayService) ApplyPendingRestart() {
|
||||
if !s.IsNeedRestartAndSetFalse() {
|
||||
return
|
||||
}
|
||||
if err := s.RestartXray(false); err != nil {
|
||||
logger.Error("restart xray failed:", err)
|
||||
s.SetToNeedRestart()
|
||||
}
|
||||
}
|
||||
|
||||
// DidXrayCrash checks if Xray crashed by verifying it's not running and wasn't manually stopped.
|
||||
func (s *XrayService) DidXrayCrash() bool {
|
||||
return !s.IsXrayRunning() && !isManuallyStopped.Load()
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRestartXrayRespectsManualStop(t *testing.T) {
|
||||
setupSettingTestDB(t)
|
||||
if err := (&SettingService{}).saveSetting("xrayTemplateConfig", "{ not valid json"); err != nil {
|
||||
t.Fatalf("seed template: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { isManuallyStopped.Store(false) })
|
||||
|
||||
isManuallyStopped.Store(true)
|
||||
_ = (&XrayService{}).RestartXray(false)
|
||||
|
||||
if !isManuallyStopped.Load() {
|
||||
t.Fatal("a non-forced restart cleared a deliberate manual stop and would revive xray")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPendingRestartReArmsFlagOnFailure(t *testing.T) {
|
||||
setupSettingTestDB(t)
|
||||
if err := (&SettingService{}).saveSetting("xrayTemplateConfig", "{ not valid json"); err != nil {
|
||||
t.Fatalf("seed template: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
isManuallyStopped.Store(false)
|
||||
isNeedXrayRestart.Store(false)
|
||||
})
|
||||
isManuallyStopped.Store(false)
|
||||
|
||||
svc := &XrayService{}
|
||||
svc.SetToNeedRestart()
|
||||
svc.ApplyPendingRestart()
|
||||
|
||||
if !isNeedXrayRestart.Load() {
|
||||
t.Fatal("a failed restart must re-arm the need-restart flag so the pending config change is retried")
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
)
|
||||
|
||||
var routingMatcherKeys = []string{
|
||||
"domain", "ip", "port", "sourcePort", "localPort", "network",
|
||||
"sourceIP", "localIP", "user", "vlessRoute", "protocol", "attrs", "process",
|
||||
"domain", "domains", "ip", "port", "sourcePort", "localPort", "network",
|
||||
"source", "sourceIP", "localIP", "user", "vlessRoute", "protocol", "attrs", "process",
|
||||
}
|
||||
|
||||
func readInboundTags(raw any) []string {
|
||||
|
||||
@@ -158,6 +158,39 @@ func TestRemoveInboundTagReferences_KeepsRuleWithOtherMatchers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveInboundTagReferences_KeepsSourceScopedRule(t *testing.T) {
|
||||
setupSettingTestDB(t)
|
||||
seedXrayTemplate(t, `{
|
||||
"routing": {
|
||||
"rules": [
|
||||
{
|
||||
"type":"field",
|
||||
"inboundTag":["in-443-tcp"],
|
||||
"source":["10.0.0.0/8"],
|
||||
"outboundTag":"blocked"
|
||||
}
|
||||
]
|
||||
}
|
||||
}`)
|
||||
|
||||
svc := &XraySettingService{}
|
||||
if _, err := svc.RemoveInboundTagReferences("in-443-tcp"); err != nil {
|
||||
t.Fatalf("RemoveInboundTagReferences: %v", err)
|
||||
}
|
||||
|
||||
got, err := svc.GetXrayConfigTemplate()
|
||||
if err != nil {
|
||||
t.Fatalf("GetXrayConfigTemplate: %v", err)
|
||||
}
|
||||
rule := findRuleByOutbound(t, got, "blocked")
|
||||
if _, ok := rule["inboundTag"]; ok {
|
||||
t.Fatalf("inboundTag should be trimmed, rule = %#v", rule)
|
||||
}
|
||||
if src, _ := rule["source"].([]any); len(src) != 1 {
|
||||
t.Fatalf("source-scoped rule was dropped instead of kept; rule = %#v", rule)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveInboundTagReferences_RemovesOneTagFromMultiInboundRule(t *testing.T) {
|
||||
setupSettingTestDB(t)
|
||||
seedXrayTemplate(t, `{
|
||||
|
||||
@@ -1410,6 +1410,7 @@
|
||||
"smtpTestSuccess": "تم إرسال البريد التجريبي بنجاح",
|
||||
"smtpHostNotConfigured": "خادم SMTP غير مهيأ",
|
||||
"smtpNoRecipients": "لا يوجد مستلمون مهيؤون",
|
||||
"smtpFromNotConfigured": "عنوان مرسل SMTP غير مُهيأ",
|
||||
"eventLoginAttempt": "محاولة تسجيل دخول",
|
||||
"telegramTokenConfigured": "مهيأ؛ اتركه فارغاً للاحتفاظ بالتوكن الحالي.",
|
||||
"telegramTokenPlaceholder": "مهيأ — أدخل توكن جديد لاستبداله",
|
||||
|
||||
@@ -1529,6 +1529,7 @@
|
||||
"smtpTestSuccess": "Test email sent successfully",
|
||||
"smtpHostNotConfigured": "SMTP host not configured",
|
||||
"smtpNoRecipients": "No recipients configured",
|
||||
"smtpFromNotConfigured": "SMTP sender address not configured",
|
||||
"eventLoginAttempt": "Login attempt",
|
||||
"telegramTokenConfigured": "Configured; leave blank to keep current token.",
|
||||
"telegramTokenPlaceholder": "Configured - enter a new token to replace",
|
||||
|
||||
@@ -1410,6 +1410,7 @@
|
||||
"smtpTestSuccess": "Correo de prueba enviado correctamente",
|
||||
"smtpHostNotConfigured": "Servidor SMTP no configurado",
|
||||
"smtpNoRecipients": "No hay destinatarios configurados",
|
||||
"smtpFromNotConfigured": "La dirección del remitente SMTP no está configurada",
|
||||
"eventLoginAttempt": "Intento de inicio de sesión",
|
||||
"telegramTokenConfigured": "Configurado; deje en blanco para mantener el token actual.",
|
||||
"telegramTokenPlaceholder": "Configurado: introduzca un nuevo token para reemplazarlo",
|
||||
|
||||
@@ -1412,6 +1412,7 @@
|
||||
"smtpTestSuccess": "ایمیل آزمایشی با موفقیت ارسال شد",
|
||||
"smtpHostNotConfigured": "میزبان SMTP پیکربندی نشده است",
|
||||
"smtpNoRecipients": "هیچ گیرندهای پیکربندی نشده است",
|
||||
"smtpFromNotConfigured": "آدرس فرستنده SMTP پیکربندی نشده است",
|
||||
"eventLoginAttempt": "تلاش برای ورود",
|
||||
"telegramTokenConfigured": "پیکربندی شده؛ برای حفظ توکن فعلی خالی بگذارید.",
|
||||
"telegramTokenPlaceholder": "پیکربندی شده - برای جایگزینی، توکن جدید وارد کنید",
|
||||
|
||||
@@ -1410,6 +1410,7 @@
|
||||
"smtpTestSuccess": "Email uji berhasil dikirim",
|
||||
"smtpHostNotConfigured": "Host SMTP belum dikonfigurasi",
|
||||
"smtpNoRecipients": "Tidak ada penerima yang dikonfigurasi",
|
||||
"smtpFromNotConfigured": "Alamat pengirim SMTP belum dikonfigurasi",
|
||||
"eventLoginAttempt": "Percobaan masuk",
|
||||
"telegramTokenConfigured": "Terkonfigurasi; kosongkan untuk mempertahankan token saat ini.",
|
||||
"telegramTokenPlaceholder": "Terkonfigurasi - masukkan token baru untuk mengganti",
|
||||
|
||||
@@ -1410,6 +1410,7 @@
|
||||
"smtpTestSuccess": "テストメールを正常に送信しました",
|
||||
"smtpHostNotConfigured": "SMTPホストが設定されていません",
|
||||
"smtpNoRecipients": "受信者が設定されていません",
|
||||
"smtpFromNotConfigured": "SMTP送信者アドレスが設定されていません",
|
||||
"eventLoginAttempt": "ログイン試行",
|
||||
"telegramTokenConfigured": "設定済み。現在のトークンを維持する場合は空欄のままにしてください。",
|
||||
"telegramTokenPlaceholder": "設定済み - 置き換えるには新しいトークンを入力してください",
|
||||
|
||||
@@ -1410,6 +1410,7 @@
|
||||
"smtpTestSuccess": "E-mail de teste enviado com sucesso",
|
||||
"smtpHostNotConfigured": "Servidor SMTP não configurado",
|
||||
"smtpNoRecipients": "Nenhum destinatário configurado",
|
||||
"smtpFromNotConfigured": "Endereço do remetente SMTP não configurado",
|
||||
"eventLoginAttempt": "Tentativa de login",
|
||||
"telegramTokenConfigured": "Configurado; deixe em branco para manter o token atual.",
|
||||
"telegramTokenPlaceholder": "Configurado - insira um novo token para substituir",
|
||||
|
||||
@@ -1410,6 +1410,7 @@
|
||||
"smtpTestSuccess": "Тестовое письмо отправлено успешно",
|
||||
"smtpHostNotConfigured": "SMTP хост не настроен",
|
||||
"smtpNoRecipients": "Получатели не настроены",
|
||||
"smtpFromNotConfigured": "Адрес отправителя SMTP не настроен",
|
||||
"eventLoginAttempt": "Попытка входа",
|
||||
"telegramTokenConfigured": "Настроен; оставьте пустым для сохранения текущего токена.",
|
||||
"telegramTokenPlaceholder": "Настроен - введите новый токен для замены",
|
||||
|
||||
@@ -1410,6 +1410,7 @@
|
||||
"smtpTestSuccess": "Test e-postası başarıyla gönderildi",
|
||||
"smtpHostNotConfigured": "SMTP sunucusu yapılandırılmamış",
|
||||
"smtpNoRecipients": "Yapılandırılmış alıcı yok",
|
||||
"smtpFromNotConfigured": "SMTP gönderen adresi yapılandırılmamış",
|
||||
"eventLoginAttempt": "Oturum açma denemesi",
|
||||
"telegramTokenConfigured": "Yapılandırıldı; mevcut belirteci korumak için boş bırakın.",
|
||||
"telegramTokenPlaceholder": "Yapılandırıldı - değiştirmek için yeni bir belirteç girin",
|
||||
|
||||
@@ -1410,6 +1410,7 @@
|
||||
"smtpTestSuccess": "Тестовий лист успішно надіслано",
|
||||
"smtpHostNotConfigured": "Хост SMTP не налаштовано",
|
||||
"smtpNoRecipients": "Отримувачів не налаштовано",
|
||||
"smtpFromNotConfigured": "Адресу відправника SMTP не налаштовано",
|
||||
"eventLoginAttempt": "Спроба входу",
|
||||
"telegramTokenConfigured": "Налаштовано; залиште порожнім, щоб зберегти поточний токен.",
|
||||
"telegramTokenPlaceholder": "Налаштовано — введіть новий токен для заміни",
|
||||
|
||||
@@ -1410,6 +1410,7 @@
|
||||
"smtpTestSuccess": "Đã gửi email thử nghiệm thành công",
|
||||
"smtpHostNotConfigured": "Chưa cấu hình máy chủ SMTP",
|
||||
"smtpNoRecipients": "Chưa cấu hình người nhận",
|
||||
"smtpFromNotConfigured": "Chưa cấu hình địa chỉ người gửi SMTP",
|
||||
"eventLoginAttempt": "Lần thử đăng nhập",
|
||||
"telegramTokenConfigured": "Đã cấu hình; để trống để giữ token hiện tại.",
|
||||
"telegramTokenPlaceholder": "Đã cấu hình - nhập token mới để thay thế",
|
||||
|
||||
@@ -1410,6 +1410,7 @@
|
||||
"smtpTestSuccess": "测试邮件发送成功",
|
||||
"smtpHostNotConfigured": "尚未配置 SMTP 主机",
|
||||
"smtpNoRecipients": "尚未配置收件人",
|
||||
"smtpFromNotConfigured": "未配置 SMTP 发件人地址",
|
||||
"eventLoginAttempt": "登录尝试",
|
||||
"telegramTokenConfigured": "已配置;留空则保留当前令牌。",
|
||||
"telegramTokenPlaceholder": "已配置——输入新令牌以替换",
|
||||
|
||||
@@ -1410,6 +1410,7 @@
|
||||
"smtpTestSuccess": "測試郵件已成功傳送",
|
||||
"smtpHostNotConfigured": "尚未設定 SMTP 主機",
|
||||
"smtpNoRecipients": "尚未設定收件人",
|
||||
"smtpFromNotConfigured": "未設定 SMTP 寄件人地址",
|
||||
"eventLoginAttempt": "登入嘗試",
|
||||
"telegramTokenConfigured": "已設定;留空以保留目前的權杖。",
|
||||
"telegramTokenPlaceholder": "已設定 - 輸入新權杖以取代",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user