mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 12:44:22 +00:00
Merge branch 'main' into fix/disabled-client-stays-connected-on-remote-node
This commit is contained in:
commit
27829c8414
49 changed files with 2275 additions and 592 deletions
|
|
@ -27,6 +27,16 @@ failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnect
|
|||
ignoreregex =
|
||||
EOF
|
||||
|
||||
# Ports to exempt from the ban so an over-limit proxy client can never lock
|
||||
# the administrator out of SSH or the panel. The ban still covers every other
|
||||
# TCP port (including all Xray inbounds), so IP-limit keeps working for inbounds
|
||||
# added later without regenerating these files.
|
||||
SSH_PORTS=$(grep -oE '^[[:space:]]*Port[[:space:]]+[0-9]+' /etc/ssh/sshd_config 2>/dev/null | grep -oE '[0-9]+' | paste -sd, -)
|
||||
[ -z "$SSH_PORTS" ] && SSH_PORTS="22"
|
||||
PANEL_PORT=$(/app/x-ui setting -show true 2>/dev/null | grep -Eo 'port: .+' | awk '{print $2}')
|
||||
EXEMPT_PORTS="$SSH_PORTS"
|
||||
[ -n "$PANEL_PORT" ] && EXEMPT_PORTS="$EXEMPT_PORTS,$PANEL_PORT"
|
||||
|
||||
cat > /etc/fail2ban/action.d/3x-ipl.conf << EOF
|
||||
[INCLUDES]
|
||||
before = iptables-allports.conf
|
||||
|
|
@ -42,16 +52,17 @@ actionstop = <iptables> -D <chain> -p <protocol> -j f2b-<name>
|
|||
|
||||
actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]'
|
||||
|
||||
actionban = <iptables> -I f2b-<name> 1 -s <ip> -j <blocktype>
|
||||
actionban = <iptables> -I f2b-<name> 1 -s <ip> -p <protocol> -m multiport ! --dports <exemptports> -j <blocktype>
|
||||
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") BAN [Email] = <F-USER> [IP] = <ip> banned for <bantime> seconds." >> $LOG_FOLDER/3xipl-banned.log
|
||||
|
||||
actionunban = <iptables> -D f2b-<name> -s <ip> -j <blocktype>
|
||||
actionunban = <iptables> -D f2b-<name> -s <ip> -p <protocol> -m multiport ! --dports <exemptports> -j <blocktype>
|
||||
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> $LOG_FOLDER/3xipl-banned.log
|
||||
|
||||
[Init]
|
||||
name = default
|
||||
protocol = tcp
|
||||
chain = INPUT
|
||||
exemptports = $EXEMPT_PORTS
|
||||
EOF
|
||||
|
||||
fail2ban-client -x start
|
||||
|
|
|
|||
|
|
@ -86,6 +86,14 @@ func MigrateData(srcPath, dstDSN string) error {
|
|||
}
|
||||
}
|
||||
|
||||
// AutoMigrate re-creates the legacy client_traffics -> inbounds foreign key,
|
||||
// but the running panel drops it (see dropLegacyForeignKeys) and tolerates
|
||||
// client_traffics rows whose inbound was deleted. Drop it here too so copying
|
||||
// such orphaned rows can't fail with an fk_inbounds_client_stats violation.
|
||||
if err := dst.Exec("ALTER TABLE client_traffics DROP CONSTRAINT IF EXISTS fk_inbounds_client_stats").Error; err != nil {
|
||||
return fmt.Errorf("drop legacy foreign key: %w", err)
|
||||
}
|
||||
|
||||
// Empty the destination tables so the migration is idempotent: a fresh
|
||||
// PostgreSQL DB already holds an auto-seeded admin (id=1) from any prior
|
||||
// panel start, and a partially-failed earlier run leaves rows behind. Either
|
||||
|
|
|
|||
38
frontend/package-lock.json
generated
38
frontend/package-lock.json
generated
|
|
@ -17,7 +17,7 @@
|
|||
"axios": "^1.17.0",
|
||||
"codemirror": "^6.0.2",
|
||||
"dayjs": "^1.11.21",
|
||||
"i18next": "^26.3.0",
|
||||
"i18next": "^26.3.1",
|
||||
"otpauth": "^9.5.1",
|
||||
"persian-calendar-suite": "^1.5.5",
|
||||
"qs": "^6.15.2",
|
||||
|
|
@ -1934,9 +1934,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1954,9 +1951,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1974,9 +1968,6 @@
|
|||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1994,9 +1985,6 @@
|
|||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2014,9 +2002,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2034,9 +2019,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -5087,9 +5069,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.3.0",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.0.tgz",
|
||||
"integrity": "sha512-gHSgGpUXVmuqE2El1W61DmxeyeTlFfZgdJRWMo9jScAn5pu7TuTuiccb1zh3E2J9hEBVGJ23+96x0ieBhfuIHA==",
|
||||
"version": "26.3.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
|
||||
"integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
|
|
@ -5615,9 +5597,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -5639,9 +5618,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -5663,9 +5639,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -5687,9 +5660,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
"axios": "^1.17.0",
|
||||
"codemirror": "^6.0.2",
|
||||
"dayjs": "^1.11.21",
|
||||
"i18next": "^26.3.0",
|
||||
"i18next": "^26.3.1",
|
||||
"otpauth": "^9.5.1",
|
||||
"persian-calendar-suite": "^1.5.5",
|
||||
"qs": "^6.15.2",
|
||||
|
|
|
|||
|
|
@ -1495,6 +1495,36 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/server/getMigration": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Server"
|
||||
],
|
||||
"summary": "Stream a cross-engine migration file as an attachment: a .dump (SQL text) on SQLite, or a .db SQLite database built from the live data on PostgreSQL.",
|
||||
"operationId": "get_panel_api_server_getMigration",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"obj": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/server/getNewUUID": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
|
@ -5761,7 +5791,7 @@
|
|||
"tags": [
|
||||
"Subscription Server"
|
||||
],
|
||||
"summary": "Return subscription as a Clash/Mihomo-compatible YAML config. Only when Clash subscription is enabled in settings. Default path: /clash/:subid.",
|
||||
"summary": "Return subscription as a Clash/Mihomo-compatible YAML config, including configured global Clash routing rules. Only when Clash subscription is enabled in settings. Default path: /clash/:subid.",
|
||||
"operationId": "get_clashPath_subid",
|
||||
"parameters": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -34,7 +34,9 @@ export interface AllSetting {
|
|||
subAnnounce: string;
|
||||
subCertFile: string;
|
||||
subClashEnable: boolean;
|
||||
subClashEnableRouting: boolean;
|
||||
subClashPath: string;
|
||||
subClashRules: string;
|
||||
subClashURI: string;
|
||||
subDomain: string;
|
||||
subEmailInRemark: boolean;
|
||||
|
|
@ -42,9 +44,8 @@ export interface AllSetting {
|
|||
subEnableRouting: boolean;
|
||||
subEncrypt: boolean;
|
||||
subJsonEnable: boolean;
|
||||
subJsonFragment: string;
|
||||
subJsonFinalMask: string;
|
||||
subJsonMux: string;
|
||||
subJsonNoises: string;
|
||||
subJsonPath: string;
|
||||
subJsonRules: string;
|
||||
subJsonURI: string;
|
||||
|
|
@ -121,7 +122,9 @@ export interface AllSettingView {
|
|||
subAnnounce: string;
|
||||
subCertFile: string;
|
||||
subClashEnable: boolean;
|
||||
subClashEnableRouting: boolean;
|
||||
subClashPath: string;
|
||||
subClashRules: string;
|
||||
subClashURI: string;
|
||||
subDomain: string;
|
||||
subEmailInRemark: boolean;
|
||||
|
|
@ -129,9 +132,8 @@ export interface AllSettingView {
|
|||
subEnableRouting: boolean;
|
||||
subEncrypt: boolean;
|
||||
subJsonEnable: boolean;
|
||||
subJsonFragment: string;
|
||||
subJsonFinalMask: string;
|
||||
subJsonMux: string;
|
||||
subJsonNoises: string;
|
||||
subJsonPath: string;
|
||||
subJsonRules: string;
|
||||
subJsonURI: string;
|
||||
|
|
|
|||
|
|
@ -36,7 +36,9 @@ export const AllSettingSchema = z.object({
|
|||
subAnnounce: z.string(),
|
||||
subCertFile: z.string(),
|
||||
subClashEnable: z.boolean(),
|
||||
subClashEnableRouting: z.boolean(),
|
||||
subClashPath: z.string(),
|
||||
subClashRules: z.string(),
|
||||
subClashURI: z.string(),
|
||||
subDomain: z.string(),
|
||||
subEmailInRemark: z.boolean(),
|
||||
|
|
@ -44,9 +46,8 @@ export const AllSettingSchema = z.object({
|
|||
subEnableRouting: z.boolean(),
|
||||
subEncrypt: z.boolean(),
|
||||
subJsonEnable: z.boolean(),
|
||||
subJsonFragment: z.string(),
|
||||
subJsonFinalMask: z.string(),
|
||||
subJsonMux: z.string(),
|
||||
subJsonNoises: z.string(),
|
||||
subJsonPath: z.string(),
|
||||
subJsonRules: z.string(),
|
||||
subJsonURI: z.string(),
|
||||
|
|
@ -124,7 +125,9 @@ export const AllSettingViewSchema = z.object({
|
|||
subAnnounce: z.string(),
|
||||
subCertFile: z.string(),
|
||||
subClashEnable: z.boolean(),
|
||||
subClashEnableRouting: z.boolean(),
|
||||
subClashPath: z.string(),
|
||||
subClashRules: z.string(),
|
||||
subClashURI: z.string(),
|
||||
subDomain: z.string(),
|
||||
subEmailInRemark: z.boolean(),
|
||||
|
|
@ -132,9 +135,8 @@ export const AllSettingViewSchema = z.object({
|
|||
subEnableRouting: z.boolean(),
|
||||
subEncrypt: z.boolean(),
|
||||
subJsonEnable: z.boolean(),
|
||||
subJsonFragment: z.string(),
|
||||
subJsonFinalMask: z.string(),
|
||||
subJsonMux: z.string(),
|
||||
subJsonNoises: z.string(),
|
||||
subJsonPath: z.string(),
|
||||
subJsonRules: z.string(),
|
||||
subJsonURI: z.string(),
|
||||
|
|
|
|||
|
|
@ -6,21 +6,15 @@ import type { NamePath } from 'antd/es/form/interface';
|
|||
import { RandomUtil } from '@/utils';
|
||||
import { OutboundProtocols } from '@/schemas/primitives';
|
||||
|
||||
// Pattern A FinalMaskForm. Renders a Fragment of Form.Items at absolute
|
||||
// paths under `name`; the parent modal owns the Form instance.
|
||||
//
|
||||
// Naming convention inside Form.List: AntD prefixes Form.Item `name`
|
||||
// with the Form.List's own `name`. So Form.Items inside the render
|
||||
// prop use RELATIVE paths (e.g. `[field.name, 'type']`). Nested
|
||||
// Form.Lists also use relative names. Using absolute paths here would
|
||||
// double up the prefix and silently route reads/writes to the wrong
|
||||
// storage path.
|
||||
|
||||
export interface FinalMaskFormProps {
|
||||
name: NamePath;
|
||||
network: string;
|
||||
protocol: string;
|
||||
form: FormInstance;
|
||||
// When true, all sections (TCP / UDP / QUIC) are shown regardless of
|
||||
// network/protocol. Used by the global sub-JSON finalmask editor where
|
||||
// the masks apply to every stream rather than one specific transport.
|
||||
showAll?: boolean;
|
||||
}
|
||||
|
||||
const TCP_NETWORKS = ['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp'];
|
||||
|
|
@ -99,12 +93,12 @@ function defaultUdpHop(): Record<string, unknown> {
|
|||
return { ports: '20000-50000', interval: '5-10' };
|
||||
}
|
||||
|
||||
export default function FinalMaskForm({ name, network, protocol, form }: FinalMaskFormProps) {
|
||||
export default function FinalMaskForm({ name, network, protocol, form, showAll = false }: FinalMaskFormProps) {
|
||||
const base = asPath(name);
|
||||
const isHysteria = protocol === OutboundProtocols.Hysteria || protocol === 'hysteria';
|
||||
const showTcp = TCP_NETWORKS.includes(network);
|
||||
const showUdp = isHysteria || network === 'kcp';
|
||||
const showQuic = isHysteria || network === 'xhttp';
|
||||
const showTcp = showAll || TCP_NETWORKS.includes(network);
|
||||
const showUdp = showAll || isHysteria || network === 'kcp';
|
||||
const showQuic = showAll || isHysteria || network === 'xhttp';
|
||||
const quicParams = Form.useWatch([...base, 'quicParams'], { form, preserve: true });
|
||||
const hasQuicParams = quicParams != null;
|
||||
|
||||
|
|
|
|||
|
|
@ -55,10 +55,11 @@ export class AllSetting {
|
|||
subURI = '';
|
||||
subJsonURI = '';
|
||||
subClashURI = '';
|
||||
subJsonFragment = '';
|
||||
subJsonNoises = '';
|
||||
subClashEnableRouting = false;
|
||||
subClashRules = '';
|
||||
subJsonMux = '';
|
||||
subJsonRules = '';
|
||||
subJsonFinalMask = '';
|
||||
|
||||
timeLocation = 'Local';
|
||||
|
||||
|
|
|
|||
|
|
@ -1114,7 +1114,7 @@ export const sections: readonly Section[] = [
|
|||
{
|
||||
method: 'GET',
|
||||
path: '/{clashPath}:subid',
|
||||
summary: 'Return subscription as a Clash/Mihomo-compatible YAML config. Only when Clash subscription is enabled in settings. Default path: /clash/:subid.',
|
||||
summary: 'Return subscription as a Clash/Mihomo-compatible YAML config, including configured global Clash routing rules. Only when Clash subscription is enabled in settings. Default path: /clash/:subid.',
|
||||
params: [
|
||||
{ name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' },
|
||||
],
|
||||
|
|
|
|||
|
|
@ -249,7 +249,7 @@ export default function ClientBulkAddModal({
|
|||
)}
|
||||
{form.emailMethod < 2 && (
|
||||
<Form.Item label={t('pages.clients.clientCount')}>
|
||||
<InputNumber value={form.quantity} min={1} max={100} onChange={(v) => update('quantity', Number(v) || 1)} />
|
||||
<InputNumber value={form.quantity} min={1} max={1000} onChange={(v) => update('quantity', Number(v) || 1)} />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ import type { ClientFilters } from './filters';
|
|||
import './ClientsPage.css';
|
||||
|
||||
const FILTER_STATE_KEY = 'clientsFilterState';
|
||||
const DISABLED_PAGE_SIZE = 200;
|
||||
|
||||
function UngroupIcon() {
|
||||
return (
|
||||
|
|
@ -276,10 +277,7 @@ export default function ClientsPage() {
|
|||
const activeCount = activeFilterCount(filters);
|
||||
|
||||
useEffect(() => {
|
||||
if (pageSize > 0) {
|
||||
|
||||
setTablePageSize(pageSize);
|
||||
}
|
||||
setTablePageSize(pageSize > 0 ? pageSize : DISABLED_PAGE_SIZE);
|
||||
}, [pageSize]);
|
||||
|
||||
const onlineSet = useMemo(() => new Set(onlines || []), [onlines]);
|
||||
|
|
|
|||
55
frontend/src/pages/settings/SubJsonFinalMaskForm.tsx
Normal file
55
frontend/src/pages/settings/SubJsonFinalMaskForm.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Form } from 'antd';
|
||||
|
||||
import { FinalMaskForm } from '@/lib/xray/forms/transport';
|
||||
import type { FinalMaskStreamSettings } from '@/schemas/protocols/stream/finalmask';
|
||||
|
||||
interface SubJsonFinalMaskFormProps {
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
}
|
||||
|
||||
function hasValue(v: unknown): boolean {
|
||||
if (v == null) return false;
|
||||
if (Array.isArray(v)) return v.some(hasValue);
|
||||
if (typeof v === 'object') return Object.values(v as Record<string, unknown>).some(hasValue);
|
||||
if (typeof v === 'string') return v.length > 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
function parseFinalMask(raw: string): FinalMaskStreamSettings {
|
||||
try {
|
||||
if (raw) return JSON.parse(raw) as FinalMaskStreamSettings;
|
||||
} catch {
|
||||
return { tcp: [], udp: [] };
|
||||
}
|
||||
return { tcp: [], udp: [] };
|
||||
}
|
||||
|
||||
export default function SubJsonFinalMaskForm({ value, onChange }: SubJsonFinalMaskFormProps) {
|
||||
const [form] = Form.useForm();
|
||||
const [initial] = useState(() => parseFinalMask(value));
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
const finalmask = Form.useWatch('finalmask', form) as FinalMaskStreamSettings | undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (finalmask === undefined) return;
|
||||
const next = hasValue(finalmask) ? JSON.stringify(finalmask) : '';
|
||||
if (next !== value) onChangeRef.current(next);
|
||||
}, [finalmask, value]);
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
layout="horizontal"
|
||||
labelCol={{ flex: '160px' }}
|
||||
wrapperCol={{ flex: 'auto' }}
|
||||
colon={false}
|
||||
initialValues={{ finalmask: initial }}
|
||||
>
|
||||
<FinalMaskForm name="finalmask" network="" protocol="" form={form} showAll />
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +1,6 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
|
|
@ -10,19 +8,17 @@ import {
|
|||
Tabs,
|
||||
} from 'antd';
|
||||
import {
|
||||
DeleteOutlined,
|
||||
PartitionOutlined,
|
||||
PlusOutlined,
|
||||
ScissorOutlined,
|
||||
RocketOutlined,
|
||||
SendOutlined,
|
||||
SettingOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { AllSetting } from '@/models/setting';
|
||||
import { SettingListItem } from '@/components/ui';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { catTabLabel } from './catTabLabel';
|
||||
import { sanitizePath, normalizePath } from './uriPath';
|
||||
import SubJsonFinalMaskForm from './SubJsonFinalMaskForm';
|
||||
import './SubscriptionFormatsTab.css';
|
||||
|
||||
interface SubscriptionFormatsTabProps {
|
||||
|
|
@ -30,15 +26,6 @@ interface SubscriptionFormatsTabProps {
|
|||
updateSetting: (patch: Partial<AllSetting>) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_FRAGMENT = {
|
||||
packets: 'tlshello',
|
||||
length: '100-200',
|
||||
interval: '10-20',
|
||||
maxSplit: '300-400',
|
||||
};
|
||||
const DEFAULT_NOISES: { type: string; packet: string; delay: string; applyTo: string }[] = [
|
||||
{ type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ip' },
|
||||
];
|
||||
const DEFAULT_MUX = {
|
||||
enabled: true,
|
||||
concurrency: 8,
|
||||
|
|
@ -85,55 +72,9 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
|
|||
const { t } = useTranslation();
|
||||
const { isMobile } = useMediaQuery();
|
||||
|
||||
const fragment = allSetting.subJsonFragment !== '';
|
||||
const noisesEnabled = allSetting.subJsonNoises !== '';
|
||||
const muxEnabled = allSetting.subJsonMux !== '';
|
||||
const directEnabled = allSetting.subJsonRules !== '';
|
||||
|
||||
const fragmentObj = useMemo(
|
||||
() => (fragment ? readJson<typeof DEFAULT_FRAGMENT>(allSetting.subJsonFragment, DEFAULT_FRAGMENT) : DEFAULT_FRAGMENT),
|
||||
[allSetting.subJsonFragment, fragment],
|
||||
);
|
||||
|
||||
function setFragmentEnabled(v: boolean) {
|
||||
updateSetting({ subJsonFragment: v ? JSON.stringify(DEFAULT_FRAGMENT) : '' });
|
||||
}
|
||||
|
||||
function setFragmentField<K extends keyof typeof DEFAULT_FRAGMENT>(key: K, value: string) {
|
||||
if (value === '') return;
|
||||
const next = { ...fragmentObj, [key]: value };
|
||||
updateSetting({ subJsonFragment: JSON.stringify(next) });
|
||||
}
|
||||
|
||||
const noisesArray = useMemo(
|
||||
() => (noisesEnabled ? readJson<typeof DEFAULT_NOISES>(allSetting.subJsonNoises, DEFAULT_NOISES) : []),
|
||||
[allSetting.subJsonNoises, noisesEnabled],
|
||||
);
|
||||
|
||||
function setNoisesEnabled(v: boolean) {
|
||||
updateSetting({ subJsonNoises: v ? JSON.stringify(DEFAULT_NOISES) : '' });
|
||||
}
|
||||
|
||||
function setNoisesArray(next: typeof DEFAULT_NOISES) {
|
||||
if (noisesEnabled) updateSetting({ subJsonNoises: JSON.stringify(next) });
|
||||
}
|
||||
|
||||
function addNoise() {
|
||||
setNoisesArray([...noisesArray, { ...DEFAULT_NOISES[0] }]);
|
||||
}
|
||||
|
||||
function removeNoise(index: number) {
|
||||
const next = [...noisesArray];
|
||||
next.splice(index, 1);
|
||||
setNoisesArray(next);
|
||||
}
|
||||
|
||||
function updateNoiseField(index: number, field: keyof typeof DEFAULT_NOISES[number], value: string) {
|
||||
const next = [...noisesArray];
|
||||
next[index] = { ...next[index], [field]: value };
|
||||
setNoisesArray(next);
|
||||
}
|
||||
|
||||
const muxObj = useMemo(
|
||||
() => (muxEnabled ? readJson<typeof DEFAULT_MUX>(allSetting.subJsonMux, DEFAULT_MUX) : DEFAULT_MUX),
|
||||
[allSetting.subJsonMux, muxEnabled],
|
||||
|
|
@ -251,98 +192,19 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
|
|||
},
|
||||
{
|
||||
key: '2',
|
||||
label: catTabLabel(<ScissorOutlined />, t('pages.settings.fragment'), isMobile),
|
||||
label: catTabLabel(<RocketOutlined />, t('pages.settings.subFormats.finalMask'), isMobile),
|
||||
children: (
|
||||
<>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.fragment')} description={t('pages.settings.fragmentDesc')}>
|
||||
<Switch checked={fragment} onChange={setFragmentEnabled} />
|
||||
</SettingListItem>
|
||||
{fragment && (
|
||||
<div className="format-settings">
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subFormats.packets')}>
|
||||
<Input value={fragmentObj.packets} placeholder="1-1 | 1-3 | tlshello | …"
|
||||
onChange={(e) => setFragmentField('packets', e.target.value)} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subFormats.length')}>
|
||||
<Input value={fragmentObj.length} placeholder="100-200"
|
||||
onChange={(e) => setFragmentField('length', e.target.value)} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subFormats.interval')}>
|
||||
<Input value={fragmentObj.interval} placeholder="10-20"
|
||||
onChange={(e) => setFragmentField('interval', e.target.value)} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subFormats.maxSplit')}>
|
||||
<Input value={fragmentObj.maxSplit} placeholder="300-400"
|
||||
onChange={(e) => setFragmentField('maxSplit', e.target.value)} />
|
||||
</SettingListItem>
|
||||
</div>
|
||||
)}
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subFormats.finalMask')} description={t('pages.settings.subFormats.finalMaskDesc')} />
|
||||
<SubJsonFinalMaskForm
|
||||
value={allSetting.subJsonFinalMask}
|
||||
onChange={(v) => updateSetting({ subJsonFinalMask: v })}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
label: catTabLabel(<ThunderboltOutlined />, t('pages.settings.subFormats.noises'), isMobile),
|
||||
children: (
|
||||
<>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subFormats.noises')} description={t('pages.settings.noisesDesc')}>
|
||||
<Switch checked={noisesEnabled} onChange={setNoisesEnabled} />
|
||||
</SettingListItem>
|
||||
{noisesEnabled && (
|
||||
<div className="format-settings-list">
|
||||
{noisesArray.map((noise, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
size="small"
|
||||
className="noise-card"
|
||||
title={t('pages.settings.subFormats.noiseItem', { n: index + 1 })}
|
||||
extra={noisesArray.length > 1 ? (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
aria-label={t('delete')}
|
||||
onClick={() => removeNoise(index)}
|
||||
/>
|
||||
) : null}
|
||||
styles={{ body: { padding: 0 } }}
|
||||
>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subFormats.type')}>
|
||||
<Select
|
||||
value={noise.type}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(v) => updateNoiseField(index, 'type', v)}
|
||||
options={['rand', 'base64', 'str', 'hex'].map((p) => ({ value: p, label: p }))}
|
||||
/>
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subFormats.packet')}>
|
||||
<Input value={noise.packet} placeholder="5-10"
|
||||
onChange={(e) => updateNoiseField(index, 'packet', e.target.value)} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subFormats.delayMs')}>
|
||||
<Input value={noise.delay} placeholder="10-20"
|
||||
onChange={(e) => updateNoiseField(index, 'delay', e.target.value)} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subFormats.applyTo')}>
|
||||
<Select
|
||||
value={noise.applyTo}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(v) => updateNoiseField(index, 'applyTo', v)}
|
||||
options={['ip', 'ipv4', 'ipv6'].map((p) => ({ value: p, label: p }))}
|
||||
/>
|
||||
</SettingListItem>
|
||||
</Card>
|
||||
))}
|
||||
<Button type="dashed" block icon={<PlusOutlined />} onClick={addNoise}>
|
||||
{t('pages.settings.subFormats.addNoise')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: '4',
|
||||
label: catTabLabel(<PartitionOutlined />, t('pages.settings.mux'), isMobile),
|
||||
children: (
|
||||
<>
|
||||
|
|
@ -373,7 +235,7 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
|
|||
),
|
||||
},
|
||||
{
|
||||
key: '5',
|
||||
key: '4',
|
||||
label: catTabLabel(<SendOutlined />, t('pages.settings.direct'), isMobile),
|
||||
children: (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -166,6 +166,20 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
|
|||
<Input.TextArea value={allSetting.subRoutingRules} placeholder="happ://routing/add/..."
|
||||
onChange={(e) => updateSetting({ subRoutingRules: e.target.value })} />
|
||||
</SettingListItem>
|
||||
|
||||
<Divider>Clash / Mihomo</Divider>
|
||||
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subClashEnableRouting')} description={t('pages.settings.subClashEnableRoutingDesc')}>
|
||||
<Switch checked={allSetting.subClashEnableRouting} onChange={(v) => updateSetting({ subClashEnableRouting: v })} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subClashRoutingRules')} description={t('pages.settings.subClashRoutingRulesDesc')}>
|
||||
<Input.TextArea
|
||||
value={allSetting.subClashRules}
|
||||
rows={8}
|
||||
placeholder={'GEOSITE,category-ir,DIRECT\nGEOIP,private,DIRECT'}
|
||||
onChange={(e) => updateSetting({ subClashRules: e.target.value })}
|
||||
/>
|
||||
</SettingListItem>
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -182,7 +182,7 @@ export const ClientBulkAddFormSchema = z.object({
|
|||
lastNum: z.number().int().min(1),
|
||||
emailPrefix: z.string(),
|
||||
emailPostfix: z.string(),
|
||||
quantity: z.number().int().min(1).max(100),
|
||||
quantity: z.number().int().min(1).max(1000),
|
||||
subId: z.string(),
|
||||
group: z.string(),
|
||||
comment: z.string(),
|
||||
|
|
|
|||
|
|
@ -59,10 +59,11 @@ export const AllSettingSchema = z.object({
|
|||
subURI: z.string().optional(),
|
||||
subJsonURI: z.string().optional(),
|
||||
subClashURI: z.string().optional(),
|
||||
subJsonFragment: z.string().optional(),
|
||||
subJsonNoises: z.string().optional(),
|
||||
subClashEnableRouting: z.boolean().optional(),
|
||||
subClashRules: z.string().optional(),
|
||||
subJsonMux: z.string().optional(),
|
||||
subJsonRules: z.string().optional(),
|
||||
subJsonFinalMask: z.string().optional(),
|
||||
timeLocation: z.string().optional(),
|
||||
ldapEnable: z.boolean().optional(),
|
||||
ldapHost: z.string().optional(),
|
||||
|
|
|
|||
|
|
@ -858,13 +858,13 @@ export class LanguageManager {
|
|||
});
|
||||
|
||||
if (LanguageManager.isSupportLanguage(lang)) {
|
||||
CookieManager.setCookie('lang', lang);
|
||||
CookieManager.setCookie('lang', lang, 365);
|
||||
} else {
|
||||
CookieManager.setCookie('lang', 'en-US');
|
||||
CookieManager.setCookie('lang', 'en-US', 365);
|
||||
window.location.reload();
|
||||
}
|
||||
} else {
|
||||
CookieManager.setCookie('lang', 'en-US');
|
||||
CookieManager.setCookie('lang', 'en-US', 365);
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
|
|
@ -875,7 +875,7 @@ export class LanguageManager {
|
|||
if (!LanguageManager.isSupportLanguage(language)) {
|
||||
language = 'en-US';
|
||||
}
|
||||
CookieManager.setCookie('lang', language);
|
||||
CookieManager.setCookie('lang', language, 365);
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
|
|
|
|||
32
install.sh
32
install.sh
|
|
@ -297,7 +297,7 @@ setup_ssl_certificate() {
|
|||
if [ $? -ne 0 ]; then
|
||||
echo -e "${yellow}Failed to issue certificate for ${domain}${plain}"
|
||||
echo -e "${yellow}Please ensure port 80 is open and try again later with: x-ui${plain}"
|
||||
rm -rf ~/.acme.sh/${domain} 2> /dev/null
|
||||
rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc 2> /dev/null
|
||||
rm -rf "$certPath" 2> /dev/null
|
||||
return 1
|
||||
fi
|
||||
|
|
@ -431,8 +431,8 @@ setup_ip_certificate() {
|
|||
echo -e "${red}Failed to issue IP certificate${plain}"
|
||||
echo -e "${yellow}Please ensure port ${WebPort} is reachable (or forwarded from external port 80)${plain}"
|
||||
# Cleanup acme.sh data for both IPv4 and IPv6 if specified
|
||||
rm -rf ~/.acme.sh/${ipv4} 2> /dev/null
|
||||
[[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} 2> /dev/null
|
||||
rm -rf ~/.acme.sh/${ipv4} ~/.acme.sh/${ipv4}_ecc 2> /dev/null
|
||||
[[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} ~/.acme.sh/${ipv6}_ecc 2> /dev/null
|
||||
rm -rf ${certDir} 2> /dev/null
|
||||
return 1
|
||||
fi
|
||||
|
|
@ -451,8 +451,8 @@ setup_ip_certificate() {
|
|||
if [[ ! -f "${certDir}/fullchain.pem" || ! -f "${certDir}/privkey.pem" ]]; then
|
||||
echo -e "${red}Certificate files not found after installation${plain}"
|
||||
# Cleanup acme.sh data for both IPv4 and IPv6 if specified
|
||||
rm -rf ~/.acme.sh/${ipv4} 2> /dev/null
|
||||
[[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} 2> /dev/null
|
||||
rm -rf ~/.acme.sh/${ipv4} ~/.acme.sh/${ipv4}_ecc 2> /dev/null
|
||||
[[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} ~/.acme.sh/${ipv6}_ecc 2> /dev/null
|
||||
rm -rf ${certDir} 2> /dev/null
|
||||
return 1
|
||||
fi
|
||||
|
|
@ -524,14 +524,30 @@ ssl_cert_issue() {
|
|||
echo -e "${green}Your domain is: ${domain}, checking it...${plain}"
|
||||
SSL_ISSUED_DOMAIN="${domain}"
|
||||
|
||||
# detect existing certificate and reuse it if present
|
||||
# detect existing certificate and reuse it only if its files are actually
|
||||
# present and non-empty. acme.sh stores ECC certs under ${domain}_ecc and RSA
|
||||
# certs under ${domain}; a failed issuance can leave a domain entry in --list
|
||||
# with no usable cert files, which must not be reused (it produces a 0-byte
|
||||
# fullchain.pem). Broken partial state is cleaned up so issuance can proceed.
|
||||
local cert_exists=0
|
||||
if ~/.acme.sh/acme.sh --list 2> /dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
|
||||
local acmeCertDir=""
|
||||
if [[ -s ~/.acme.sh/${domain}_ecc/fullchain.cer && -s ~/.acme.sh/${domain}_ecc/${domain}.key ]]; then
|
||||
acmeCertDir=~/.acme.sh/${domain}_ecc
|
||||
elif [[ -s ~/.acme.sh/${domain}/fullchain.cer && -s ~/.acme.sh/${domain}/${domain}.key ]]; then
|
||||
acmeCertDir=~/.acme.sh/${domain}
|
||||
fi
|
||||
if [[ -n "${acmeCertDir}" ]]; then
|
||||
cert_exists=1
|
||||
local certInfo=$(~/.acme.sh/acme.sh --list 2> /dev/null | grep -F "${domain}")
|
||||
echo -e "${yellow}Existing certificate found for ${domain}, will reuse it.${plain}"
|
||||
[[ -n "${certInfo}" ]] && echo "$certInfo"
|
||||
else
|
||||
echo -e "${yellow}Found incomplete acme.sh state for ${domain} (no valid certificate files); cleaning it up and re-issuing.${plain}"
|
||||
rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
|
||||
fi
|
||||
fi
|
||||
if [[ ${cert_exists} -eq 0 ]]; then
|
||||
echo -e "${green}Your domain is ready for issuing certificates now...${plain}"
|
||||
fi
|
||||
|
||||
|
|
@ -563,7 +579,7 @@ ssl_cert_issue() {
|
|||
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${red}Issuing certificate failed, please check logs.${plain}"
|
||||
rm -rf ~/.acme.sh/${domain}
|
||||
rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
|
||||
systemctl start x-ui 2> /dev/null || rc-service x-ui start 2> /dev/null
|
||||
return 1
|
||||
else
|
||||
|
|
@ -617,7 +633,7 @@ ssl_cert_issue() {
|
|||
else
|
||||
echo -e "${red}Installing certificate failed, exiting.${plain}"
|
||||
if [[ ${cert_exists} -eq 0 ]]; then
|
||||
rm -rf ~/.acme.sh/${domain}
|
||||
rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
|
||||
fi
|
||||
systemctl start x-ui 2> /dev/null || rc-service x-ui start 2> /dev/null
|
||||
return 1
|
||||
|
|
|
|||
27
sub/sub.go
27
sub/sub.go
|
|
@ -120,16 +120,6 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
|||
SubUpdates = "10"
|
||||
}
|
||||
|
||||
SubJsonFragment, err := s.settingService.GetSubJsonFragment()
|
||||
if err != nil {
|
||||
SubJsonFragment = ""
|
||||
}
|
||||
|
||||
SubJsonNoises, err := s.settingService.GetSubJsonNoises()
|
||||
if err != nil {
|
||||
SubJsonNoises = ""
|
||||
}
|
||||
|
||||
SubJsonMux, err := s.settingService.GetSubJsonMux()
|
||||
if err != nil {
|
||||
SubJsonMux = ""
|
||||
|
|
@ -140,6 +130,21 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
|||
SubJsonRules = ""
|
||||
}
|
||||
|
||||
SubJsonFinalMask, err := s.settingService.GetSubJsonFinalMask()
|
||||
if err != nil {
|
||||
SubJsonFinalMask = ""
|
||||
}
|
||||
|
||||
SubClashEnableRouting, err := s.settingService.GetSubClashEnableRouting()
|
||||
if err != nil {
|
||||
SubClashEnableRouting = false
|
||||
}
|
||||
|
||||
SubClashRules, err := s.settingService.GetSubClashRules()
|
||||
if err != nil {
|
||||
SubClashRules = ""
|
||||
}
|
||||
|
||||
SubTitle, err := s.settingService.GetSubTitle()
|
||||
if err != nil {
|
||||
SubTitle = ""
|
||||
|
|
@ -226,7 +231,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
|||
|
||||
s.sub = NewSUBController(
|
||||
g, LinksPath, JsonPath, ClashPath, subJsonEnable, subClashEnable, Encrypt, ShowInfo, RemarkModel, SubUpdates,
|
||||
SubJsonFragment, SubJsonNoises, SubJsonMux, SubJsonRules, SubTitle, SubSupportUrl,
|
||||
SubJsonMux, SubJsonRules, SubJsonFinalMask, SubClashEnableRouting, SubClashRules, SubTitle, SubSupportUrl,
|
||||
SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules)
|
||||
|
||||
return engine, nil
|
||||
|
|
|
|||
|
|
@ -15,17 +15,13 @@ import (
|
|||
|
||||
type SubClashService struct {
|
||||
inboundService service.InboundService
|
||||
enableRouting bool
|
||||
clashRules string
|
||||
SubService *SubService
|
||||
}
|
||||
|
||||
type ClashConfig struct {
|
||||
Proxies []map[string]any `yaml:"proxies"`
|
||||
ProxyGroups []map[string]any `yaml:"proxy-groups"`
|
||||
Rules []string `yaml:"rules"`
|
||||
}
|
||||
|
||||
func NewSubClashService(subService *SubService) *SubClashService {
|
||||
return &SubClashService{SubService: subService}
|
||||
func NewSubClashService(enableRouting bool, clashRules string, subService *SubService) *SubClashService {
|
||||
return &SubClashService{enableRouting: enableRouting, clashRules: clashRules, SubService: subService}
|
||||
}
|
||||
|
||||
func (s *SubClashService) GetClash(subId string, host string) (string, string, error) {
|
||||
|
|
@ -76,14 +72,20 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
|
|||
}
|
||||
proxyNames = append(proxyNames, "DIRECT")
|
||||
|
||||
config := ClashConfig{
|
||||
Proxies: proxies,
|
||||
ProxyGroups: []map[string]any{{
|
||||
config := map[string]any{
|
||||
"proxies": proxies,
|
||||
"proxy-groups": []map[string]any{{
|
||||
"name": "PROXY",
|
||||
"type": "select",
|
||||
"proxies": proxyNames,
|
||||
}},
|
||||
Rules: []string{"MATCH,PROXY"},
|
||||
"rules": []string{"MATCH,PROXY"},
|
||||
}
|
||||
|
||||
if s.enableRouting {
|
||||
if err := mergeClashRulesYAML(config, s.clashRules); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
|
||||
finalYAML, err := yaml.Marshal(config)
|
||||
|
|
@ -554,3 +556,96 @@ func cloneMap(src map[string]any) map[string]any {
|
|||
maps.Copy(dst, src)
|
||||
return dst
|
||||
}
|
||||
|
||||
func mergeClashRulesYAML(base map[string]any, raw string) error {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var custom any
|
||||
if err := yaml.Unmarshal([]byte(raw), &custom); err != nil {
|
||||
mergeClashRules(base, linesToClashRules(raw))
|
||||
return nil
|
||||
}
|
||||
|
||||
switch typed := custom.(type) {
|
||||
case []any:
|
||||
mergeClashRules(base, typed)
|
||||
case map[string]any:
|
||||
if rules, ok := typed["rules"]; ok {
|
||||
if ruleList, ok := asAnySlice(rules); ok {
|
||||
mergeClashRules(base, ruleList)
|
||||
}
|
||||
}
|
||||
default:
|
||||
mergeClashRules(base, linesToClashRules(raw))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeClashRules(base map[string]any, customRules []any) {
|
||||
if len(customRules) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
baseRules, _ := asAnySlice(base["rules"])
|
||||
if hasClashMatchRule(customRules) {
|
||||
base["rules"] = customRules
|
||||
return
|
||||
}
|
||||
|
||||
merged := make([]any, 0, len(customRules)+len(baseRules))
|
||||
merged = append(merged, customRules...)
|
||||
merged = append(merged, baseRules...)
|
||||
base["rules"] = merged
|
||||
}
|
||||
|
||||
func asAnySlice(value any) ([]any, bool) {
|
||||
switch typed := value.(type) {
|
||||
case []any:
|
||||
return typed, true
|
||||
case []string:
|
||||
out := make([]any, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, true
|
||||
case []map[string]any:
|
||||
out := make([]any, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func hasClashMatchRule(rules []any) bool {
|
||||
for _, rule := range rules {
|
||||
ruleText, ok := rule.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(ruleText, ",", 2)
|
||||
if strings.EqualFold(strings.TrimSpace(parts[0]), "MATCH") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func linesToClashRules(raw string) []any {
|
||||
lines := strings.Split(raw, "\n")
|
||||
rules := make([]any, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
rules = append(rules, line)
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,10 +62,11 @@ func NewSUBController(
|
|||
showInfo bool,
|
||||
rModel string,
|
||||
update string,
|
||||
jsonFragment string,
|
||||
jsonNoise string,
|
||||
jsonMux string,
|
||||
jsonRules string,
|
||||
jsonFinalMask string,
|
||||
clashEnableRouting bool,
|
||||
clashRules string,
|
||||
subTitle string,
|
||||
subSupportUrl string,
|
||||
subProfileUrl string,
|
||||
|
|
@ -90,8 +91,8 @@ func NewSUBController(
|
|||
updateInterval: update,
|
||||
|
||||
subService: sub,
|
||||
subJsonService: NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub),
|
||||
subClashService: NewSubClashService(sub),
|
||||
subJsonService: NewSubJsonService(jsonMux, jsonRules, jsonFinalMask, sub),
|
||||
subClashService: NewSubClashService(clashEnableRouting, clashRules, sub),
|
||||
}
|
||||
a.initRouter(g)
|
||||
return a
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ var defaultJson string
|
|||
type SubJsonService struct {
|
||||
configJson map[string]any
|
||||
defaultOutbounds []json_util.RawMessage
|
||||
fragmentOrNoises bool
|
||||
finalMask string
|
||||
mux string
|
||||
|
||||
inboundService service.InboundService
|
||||
|
|
@ -29,7 +29,7 @@ type SubJsonService struct {
|
|||
}
|
||||
|
||||
// NewSubJsonService creates a new JSON subscription service with the given configuration.
|
||||
func NewSubJsonService(fragment string, noises string, mux string, rules string, subService *SubService) *SubJsonService {
|
||||
func NewSubJsonService(mux string, rules string, finalMask string, subService *SubService) *SubJsonService {
|
||||
var configJson map[string]any
|
||||
var defaultOutbounds []json_util.RawMessage
|
||||
json.Unmarshal([]byte(defaultJson), &configJson)
|
||||
|
|
@ -40,31 +40,6 @@ func NewSubJsonService(fragment string, noises string, mux string, rules string,
|
|||
}
|
||||
}
|
||||
|
||||
fragmentOrNoises := false
|
||||
if fragment != "" || noises != "" {
|
||||
fragmentOrNoises = true
|
||||
defaultOutboundsSettings := map[string]any{
|
||||
"domainStrategy": "UseIP",
|
||||
"redirect": "",
|
||||
}
|
||||
|
||||
if fragment != "" {
|
||||
defaultOutboundsSettings["fragment"] = json_util.RawMessage(fragment)
|
||||
}
|
||||
|
||||
if noises != "" {
|
||||
defaultOutboundsSettings["noises"] = json_util.RawMessage(noises)
|
||||
}
|
||||
|
||||
defaultDirectOutbound := map[string]any{
|
||||
"protocol": "freedom",
|
||||
"settings": defaultOutboundsSettings,
|
||||
"tag": "direct_out",
|
||||
}
|
||||
jsonBytes, _ := json.MarshalIndent(defaultDirectOutbound, "", " ")
|
||||
defaultOutbounds = append(defaultOutbounds, jsonBytes)
|
||||
}
|
||||
|
||||
if rules != "" {
|
||||
var newRules []any
|
||||
routing, _ := configJson["routing"].(map[string]any)
|
||||
|
|
@ -78,7 +53,7 @@ func NewSubJsonService(fragment string, noises string, mux string, rules string,
|
|||
return &SubJsonService{
|
||||
configJson: configJson,
|
||||
defaultOutbounds: defaultOutbounds,
|
||||
fragmentOrNoises: fragmentOrNoises,
|
||||
finalMask: finalMask,
|
||||
mux: mux,
|
||||
SubService: subService,
|
||||
}
|
||||
|
|
@ -230,8 +205,8 @@ func (s *SubJsonService) streamData(stream string) map[string]any {
|
|||
}
|
||||
delete(streamSettings, "sockopt")
|
||||
|
||||
if s.fragmentOrNoises {
|
||||
streamSettings["sockopt"] = json_util.RawMessage(`{"dialerProxy": "direct_out", "tcpKeepAliveIdle": 100}`)
|
||||
if s.finalMask != "" {
|
||||
s.applyGlobalFinalMask(streamSettings)
|
||||
}
|
||||
|
||||
// remove proxy protocol
|
||||
|
|
@ -255,6 +230,17 @@ func (s *SubJsonService) streamData(stream string) map[string]any {
|
|||
return streamSettings
|
||||
}
|
||||
|
||||
func (s *SubJsonService) applyGlobalFinalMask(streamSettings map[string]any) {
|
||||
var fm map[string]any
|
||||
if err := json.Unmarshal([]byte(s.finalMask), &fm); err != nil || len(fm) == 0 {
|
||||
return
|
||||
}
|
||||
merged := mergeFinalMask(streamSettings["finalmask"], fm)
|
||||
if len(merged) > 0 {
|
||||
streamSettings["finalmask"] = merged
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SubJsonService) removeAcceptProxy(setting any) map[string]any {
|
||||
netSettings, ok := setting.(map[string]any)
|
||||
if ok {
|
||||
|
|
@ -307,17 +293,6 @@ func (s *SubJsonService) realityData(rData map[string]any) map[string]any {
|
|||
|
||||
func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client) json_util.RawMessage {
|
||||
outbound := Outbound{}
|
||||
usersData := make([]UserVnext, 1)
|
||||
|
||||
usersData[0].ID = client.ID
|
||||
usersData[0].Email = client.Email
|
||||
usersData[0].Security = client.Security
|
||||
vnextData := make([]VnextSetting, 1)
|
||||
vnextData[0] = VnextSetting{
|
||||
Address: inbound.Listen,
|
||||
Port: inbound.Port,
|
||||
Users: usersData,
|
||||
}
|
||||
|
||||
outbound.Protocol = string(inbound.Protocol)
|
||||
outbound.Tag = "proxy"
|
||||
|
|
@ -325,8 +300,17 @@ func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_ut
|
|||
outbound.Mux = json_util.RawMessage(s.mux)
|
||||
}
|
||||
outbound.StreamSettings = streamSettings
|
||||
|
||||
security := client.Security
|
||||
if security == "" {
|
||||
security = "auto"
|
||||
}
|
||||
outbound.Settings = map[string]any{
|
||||
"vnext": vnextData,
|
||||
"address": inbound.Listen,
|
||||
"port": inbound.Port,
|
||||
"id": client.ID,
|
||||
"security": security,
|
||||
"level": 8,
|
||||
}
|
||||
|
||||
result, _ := json.MarshalIndent(outbound, "", " ")
|
||||
|
|
@ -347,24 +331,17 @@ func (s *SubJsonService) genVless(inbound *model.Inbound, streamSettings json_ut
|
|||
json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
|
||||
encryption, _ := inboundSettings["encryption"].(string)
|
||||
|
||||
user := map[string]any{
|
||||
"id": client.ID,
|
||||
"level": 8,
|
||||
"encryption": encryption,
|
||||
}
|
||||
if client.Flow != "" {
|
||||
user["flow"] = client.Flow
|
||||
}
|
||||
|
||||
vnext := map[string]any{
|
||||
settings := map[string]any{
|
||||
"address": inbound.Listen,
|
||||
"port": inbound.Port,
|
||||
"users": []any{user},
|
||||
"id": client.ID,
|
||||
"encryption": encryption,
|
||||
"level": 8,
|
||||
}
|
||||
|
||||
outbound.Settings = map[string]any{
|
||||
"vnext": []any{vnext},
|
||||
if client.Flow != "" {
|
||||
settings["flow"] = client.Flow
|
||||
}
|
||||
outbound.Settings = settings
|
||||
result, _ := json.MarshalIndent(outbound, "", " ")
|
||||
return result
|
||||
}
|
||||
|
|
@ -400,9 +377,17 @@ func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_u
|
|||
outbound.Mux = json_util.RawMessage(s.mux)
|
||||
}
|
||||
outbound.StreamSettings = streamSettings
|
||||
outbound.Settings = map[string]any{
|
||||
"servers": serverData,
|
||||
|
||||
settings := map[string]any{
|
||||
"address": serverData[0].Address,
|
||||
"port": serverData[0].Port,
|
||||
"password": serverData[0].Password,
|
||||
"level": 8,
|
||||
}
|
||||
if inbound.Protocol == model.Shadowsocks {
|
||||
settings["method"] = serverData[0].Method
|
||||
}
|
||||
outbound.Settings = settings
|
||||
|
||||
result, _ := json.MarshalIndent(outbound, "", " ")
|
||||
return result
|
||||
|
|
@ -442,7 +427,7 @@ func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any,
|
|||
newStream["hysteriaSettings"] = outHyStream
|
||||
|
||||
if finalmask, ok := hyStream["finalmask"].(map[string]any); ok {
|
||||
newStream["finalmask"] = finalmask
|
||||
newStream["finalmask"] = mergeFinalMask(newStream["finalmask"], finalmask)
|
||||
}
|
||||
|
||||
newStream["network"] = "hysteria"
|
||||
|
|
@ -454,6 +439,41 @@ func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any,
|
|||
return result
|
||||
}
|
||||
|
||||
func mergeFinalMask(base any, extra map[string]any) map[string]any {
|
||||
merged := map[string]any{}
|
||||
if baseMap, ok := base.(map[string]any); ok {
|
||||
for key, value := range baseMap {
|
||||
switch key {
|
||||
case "tcp", "udp":
|
||||
if masks, ok := value.([]any); ok {
|
||||
merged[key] = append([]any(nil), masks...)
|
||||
}
|
||||
default:
|
||||
merged[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for key, value := range extra {
|
||||
switch key {
|
||||
case "tcp", "udp":
|
||||
baseMasks, _ := merged[key].([]any)
|
||||
extraMasks, _ := value.([]any)
|
||||
if len(extraMasks) > 0 {
|
||||
merged[key] = append(baseMasks, extraMasks...)
|
||||
}
|
||||
case "quicParams":
|
||||
if _, exists := merged[key]; !exists {
|
||||
merged[key] = value
|
||||
}
|
||||
default:
|
||||
merged[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
type Outbound struct {
|
||||
Protocol string `json:"protocol"`
|
||||
Tag string `json:"tag"`
|
||||
|
|
@ -462,18 +482,6 @@ type Outbound struct {
|
|||
Settings map[string]any `json:"settings,omitempty"`
|
||||
}
|
||||
|
||||
type VnextSetting struct {
|
||||
Address string `json:"address"`
|
||||
Port int `json:"port"`
|
||||
Users []UserVnext `json:"users"`
|
||||
}
|
||||
|
||||
type UserVnext struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Security string `json:"security,omitempty"`
|
||||
}
|
||||
|
||||
type ServerSetting struct {
|
||||
Password string `json:"password"`
|
||||
Level int `json:"level"`
|
||||
|
|
|
|||
148
sub/subJsonService_test.go
Normal file
148
sub/subJsonService_test.go
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
package sub
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||
)
|
||||
|
||||
func hasDirectOutOutbound(svc *SubJsonService) bool {
|
||||
for _, raw := range svc.defaultOutbounds {
|
||||
var outbound map[string]any
|
||||
if err := json.Unmarshal(raw, &outbound); err != nil {
|
||||
continue
|
||||
}
|
||||
if outbound["tag"] == "direct_out" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func outboundSettings(t *testing.T, raw []byte) map[string]any {
|
||||
t.Helper()
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
t.Fatalf("failed to unmarshal outbound: %v", err)
|
||||
}
|
||||
settings, _ := parsed["settings"].(map[string]any)
|
||||
if settings == nil {
|
||||
t.Fatal("outbound has no settings")
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
func TestSubJsonServiceInjectsGlobalFinalMask(t *testing.T) {
|
||||
finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello","length":"100-200","delay":"10-20"}}],"udp":[{"type":"noise","settings":{"noise":[{"type":"base64","packet":"SGVsbG8="}]}}],"quicParams":{"congestion":"bbr"}}`
|
||||
svc := NewSubJsonService("", "", finalMask, nil)
|
||||
|
||||
if hasDirectOutOutbound(svc) {
|
||||
t.Fatal("direct_out outbound must never be emitted")
|
||||
}
|
||||
|
||||
stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`)
|
||||
if _, ok := stream["sockopt"]; ok {
|
||||
t.Fatal("legacy direct_out dialerProxy sockopt must never be set")
|
||||
}
|
||||
|
||||
finalmask, _ := stream["finalmask"].(map[string]any)
|
||||
if finalmask == nil {
|
||||
t.Fatal("streamSettings is missing finalmask")
|
||||
}
|
||||
|
||||
tcp, _ := finalmask["tcp"].([]any)
|
||||
if len(tcp) != 1 {
|
||||
t.Fatalf("tcp masks len = %d, want 1", len(tcp))
|
||||
}
|
||||
if first, _ := tcp[0].(map[string]any); first["type"] != "fragment" {
|
||||
t.Fatalf("tcp[0] type = %v, want fragment", first["type"])
|
||||
}
|
||||
|
||||
udp, _ := finalmask["udp"].([]any)
|
||||
if len(udp) != 1 {
|
||||
t.Fatalf("udp masks len = %d, want 1", len(udp))
|
||||
}
|
||||
|
||||
quic, _ := finalmask["quicParams"].(map[string]any)
|
||||
if quic == nil || quic["congestion"] != "bbr" {
|
||||
t.Fatalf("quicParams missing/wrong: %#v", finalmask["quicParams"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubJsonServiceMergesWithExistingFinalMask(t *testing.T) {
|
||||
finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello"}}]}`
|
||||
svc := NewSubJsonService("", "", finalMask, nil)
|
||||
|
||||
stream := svc.streamData(`{
|
||||
"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}},
|
||||
"finalmask":{"tcp":[{"type":"sudoku"}]}
|
||||
}`)
|
||||
|
||||
finalmask, _ := stream["finalmask"].(map[string]any)
|
||||
tcp, _ := finalmask["tcp"].([]any)
|
||||
if len(tcp) != 2 {
|
||||
t.Fatalf("tcp masks len = %d, want 2 (existing + global)", len(tcp))
|
||||
}
|
||||
a, _ := tcp[0].(map[string]any)
|
||||
b, _ := tcp[1].(map[string]any)
|
||||
if a["type"] != "sudoku" || b["type"] != "fragment" {
|
||||
t.Fatalf("tcp masks = %#v, want existing sudoku then global fragment", tcp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubJsonServiceNoFinalMaskWhenEmpty(t *testing.T) {
|
||||
svc := NewSubJsonService("", "", "", nil)
|
||||
stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`)
|
||||
if _, ok := stream["finalmask"]; ok {
|
||||
t.Fatal("no finalmask should be emitted when subJsonFinalMask is empty")
|
||||
}
|
||||
if _, ok := stream["sockopt"]; ok {
|
||||
t.Fatal("legacy direct_out sockopt must never be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubJsonServiceVlessFlattened(t *testing.T) {
|
||||
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
|
||||
client := model.Client{ID: "uuid-1", Flow: "xtls-rprx-vision"}
|
||||
|
||||
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVless(inbound, nil, client))
|
||||
if _, ok := settings["vnext"]; ok {
|
||||
t.Fatal("vless outbound must not use vnext")
|
||||
}
|
||||
if settings["address"] != "1.2.3.4" || settings["id"] != "uuid-1" || settings["encryption"] != "none" || settings["flow"] != "xtls-rprx-vision" {
|
||||
t.Fatalf("flat vless settings wrong: %#v", settings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubJsonServiceVmessFlattened(t *testing.T) {
|
||||
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VMESS, Settings: `{}`}
|
||||
client := model.Client{ID: "uuid-2"}
|
||||
|
||||
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVnext(inbound, nil, client))
|
||||
if _, ok := settings["vnext"]; ok {
|
||||
t.Fatal("vmess outbound must not use vnext")
|
||||
}
|
||||
if settings["id"] != "uuid-2" || settings["security"] != "auto" {
|
||||
t.Fatalf("flat vmess settings wrong: %#v", settings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubJsonServiceServerFlattened(t *testing.T) {
|
||||
trojan := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Trojan, Settings: `{}`}
|
||||
client := model.Client{Password: "p4ss"}
|
||||
|
||||
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(trojan, nil, client))
|
||||
if _, ok := settings["servers"]; ok {
|
||||
t.Fatal("trojan outbound must not use servers array")
|
||||
}
|
||||
if settings["password"] != "p4ss" || settings["address"] != "1.2.3.4" {
|
||||
t.Fatalf("flat trojan settings wrong: %#v", settings)
|
||||
}
|
||||
|
||||
ss := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Shadowsocks, Settings: `{"method":"aes-256-gcm"}`}
|
||||
ssSettings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(ss, nil, client))
|
||||
if ssSettings["method"] != "aes-256-gcm" {
|
||||
t.Fatalf("flat shadowsocks must carry method: %#v", ssSettings)
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,8 @@ package controller
|
|||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||
|
|
@ -16,6 +18,21 @@ func notifyClientsChanged() {
|
|||
websocket.BroadcastInvalidate(websocket.MessageTypeClients)
|
||||
}
|
||||
|
||||
func parseInboundIdsQuery(raw string) []int {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
ids := make([]int, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if id, err := strconv.Atoi(strings.TrimSpace(p)); err == nil {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
type ClientController struct {
|
||||
clientService service.ClientService
|
||||
inboundService service.InboundService
|
||||
|
|
@ -129,7 +146,8 @@ func (a *ClientController) update(c *gin.Context) {
|
|||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
needRestart, err := a.clientService.UpdateByEmail(&a.inboundService, email, updated)
|
||||
inboundFilter := parseInboundIdsQuery(c.Query("inboundIds"))
|
||||
needRestart, err := a.clientService.UpdateByEmail(&a.inboundService, email, updated, inboundFilter...)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -83,10 +83,11 @@ type AllSetting struct {
|
|||
SubClashEnable bool `json:"subClashEnable" form:"subClashEnable"` // Enable Clash/Mihomo subscription endpoint
|
||||
SubClashPath string `json:"subClashPath" form:"subClashPath"` // Path for Clash/Mihomo subscription endpoint
|
||||
SubClashURI string `json:"subClashURI" form:"subClashURI"` // Clash/Mihomo subscription server URI
|
||||
SubJsonFragment string `json:"subJsonFragment" form:"subJsonFragment"` // JSON subscription fragment configuration
|
||||
SubJsonNoises string `json:"subJsonNoises" form:"subJsonNoises"` // JSON subscription noise configuration
|
||||
SubClashEnableRouting bool `json:"subClashEnableRouting" form:"subClashEnableRouting"` // Enable global routing rules for Clash/Mihomo
|
||||
SubClashRules string `json:"subClashRules" form:"subClashRules"` // Clash/Mihomo global routing rules
|
||||
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration
|
||||
SubJsonRules string `json:"subJsonRules" form:"subJsonRules"`
|
||||
SubJsonFinalMask string `json:"subJsonFinalMask" form:"subJsonFinalMask"` // JSON subscription global finalmask (tcp/udp masks + quicParams)
|
||||
|
||||
// LDAP settings
|
||||
LdapEnable bool `json:"ldapEnable" form:"ldapEnable"`
|
||||
|
|
|
|||
|
|
@ -286,15 +286,17 @@ func (r *Remote) AddClient(ctx context.Context, ib *model.Inbound, client model.
|
|||
return nil
|
||||
}
|
||||
|
||||
// DeleteUser is idempotent: master's per-inbound Delete loop may call it
|
||||
// multiple times for the same node, and "not found" on the follow-ups is
|
||||
// the expected success path.
|
||||
func (r *Remote) DeleteUser(ctx context.Context, _ *model.Inbound, email string) error {
|
||||
func (r *Remote) DeleteUser(ctx context.Context, ib *model.Inbound, email string) error {
|
||||
if email == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := r.do(ctx, http.MethodPost,
|
||||
"panel/api/clients/del/"+url.PathEscape(email), nil)
|
||||
id, err := r.resolveRemoteID(ctx, ib.Tag)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
body := map[string]any{"inboundIds": []int{id}}
|
||||
_, err = r.do(ctx, http.MethodPost,
|
||||
"panel/api/clients/"+url.PathEscape(email)+"/detach", body)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -304,12 +306,17 @@ func (r *Remote) DeleteUser(ctx context.Context, _ *model.Inbound, email string)
|
|||
return err
|
||||
}
|
||||
|
||||
func (r *Remote) UpdateUser(ctx context.Context, _ *model.Inbound, oldEmail string, payload model.Client) error {
|
||||
func (r *Remote) UpdateUser(ctx context.Context, ib *model.Inbound, oldEmail string, payload model.Client) error {
|
||||
if oldEmail == "" {
|
||||
oldEmail = payload.Email
|
||||
}
|
||||
if _, err := r.do(ctx, http.MethodPost,
|
||||
"panel/api/clients/update/"+url.PathEscape(oldEmail), payload); err != nil {
|
||||
id, err := r.resolveRemoteID(ctx, ib.Tag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := "panel/api/clients/update/" + url.PathEscape(oldEmail) +
|
||||
"?inboundIds=" + strconv.Itoa(id)
|
||||
if _, err := r.do(ctx, http.MethodPost, path, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
|
|
|||
216
web/service/api_scale_postgres_test.go
Normal file
216
web/service/api_scale_postgres_test.go
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||
xuilogger "github.com/mhsanaei/3x-ui/v3/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/xray"
|
||||
|
||||
"github.com/op/go-logging"
|
||||
)
|
||||
|
||||
func seedClientTraffics(t *testing.T, inboundId int, clients []model.Client) {
|
||||
t.Helper()
|
||||
db := database.GetDB()
|
||||
rows := make([]xray.ClientTraffic, len(clients))
|
||||
for i := range clients {
|
||||
rows[i] = xray.ClientTraffic{
|
||||
InboundId: inboundId,
|
||||
Email: clients[i].Email,
|
||||
Enable: true,
|
||||
Total: clients[i].TotalGB,
|
||||
ExpiryTime: clients[i].ExpiryTime,
|
||||
}
|
||||
}
|
||||
if err := db.CreateInBatches(rows, 1000).Error; err != nil {
|
||||
t.Fatalf("seed client_traffics: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllAPIsPostgresScale exercises every client/inbound/group service method
|
||||
// reachable from the REST API at 100k/200k clients, asserting none crash on the
|
||||
// PostgreSQL bind-parameter ceiling and logging the wall-clock cost of each.
|
||||
func TestAllAPIsPostgresScale(t *testing.T) {
|
||||
if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
|
||||
t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
|
||||
}
|
||||
xuilogger.InitLogger(logging.ERROR)
|
||||
if err := database.InitDB(""); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
settingSvc := &SettingService{}
|
||||
const userId = 1
|
||||
const m = 2000
|
||||
sizes := []int{50000, 100000, 200000}
|
||||
|
||||
for _, n := range sizes {
|
||||
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
|
||||
db := database.GetDB()
|
||||
if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics, client_groups RESTART IDENTITY CASCADE").Error; err != nil {
|
||||
t.Fatalf("truncate: %v", err)
|
||||
}
|
||||
|
||||
clients := makeScaleClients(n)
|
||||
exp := time.Now().AddDate(1, 0, 0).UnixMilli()
|
||||
for i := range clients {
|
||||
clients[i].ExpiryTime = exp
|
||||
clients[i].TotalGB = 100 << 30
|
||||
}
|
||||
ib := &model.Inbound{UserId: userId, Tag: fmt.Sprintf("all-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)}
|
||||
if err := db.Create(ib).Error; err != nil {
|
||||
t.Fatalf("create inbound: %v", err)
|
||||
}
|
||||
ib2 := &model.Inbound{UserId: userId, Tag: fmt.Sprintf("all2-%d", n), Enable: true, Port: 40001, Protocol: model.VLESS, Settings: `{"clients":[]}`}
|
||||
if err := db.Create(ib2).Error; err != nil {
|
||||
t.Fatalf("create inbound2: %v", err)
|
||||
}
|
||||
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
|
||||
t.Fatalf("seed SyncInbound: %v", err)
|
||||
}
|
||||
|
||||
run := func(name string, fn func() error) {
|
||||
start := time.Now()
|
||||
if err := fn(); err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
t.Logf("N=%-7d %-26s %v", n, name, time.Since(start).Round(time.Millisecond))
|
||||
}
|
||||
|
||||
run("GetInboundDetail(noTraffic)", func() error { _, err := inboundSvc.GetInboundDetail(ib.Id); return err })
|
||||
|
||||
seedClientTraffics(t, ib.Id, clients)
|
||||
db.Exec("ANALYZE")
|
||||
|
||||
emails := make([]string, n)
|
||||
for i := 0; i < n; i++ {
|
||||
emails[i] = clients[i].Email
|
||||
}
|
||||
emailsM := emails[:m]
|
||||
|
||||
run("GetInbounds", func() error { _, err := inboundSvc.GetInbounds(userId); return err })
|
||||
run("GetInboundsSlim", func() error { _, err := inboundSvc.GetInboundsSlim(userId); return err })
|
||||
run("GetInboundDetail", func() error { _, err := inboundSvc.GetInboundDetail(ib.Id); return err })
|
||||
run("GetInboundOptions", func() error { _, err := inboundSvc.GetInboundOptions(userId); return err })
|
||||
run("ListPaged", func() error { _, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 1, PageSize: 25}); return err })
|
||||
run("ListPaged+search", func() error {
|
||||
_, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 1, PageSize: 25, Search: "user-0012345"})
|
||||
return err
|
||||
})
|
||||
run("GetClientsLastOnline", func() error { _, err := inboundSvc.GetClientsLastOnline(); return err })
|
||||
run("GetClientTrafficByEmail", func() error { _, err := inboundSvc.GetClientTrafficByEmail(emails[n/2]); return err })
|
||||
run("GetRecordByEmail", func() error { _, err := svc.GetRecordByEmail(nil, emails[n/2]); return err })
|
||||
|
||||
run("ListGroups", func() error { _, err := svc.ListGroups(); return err })
|
||||
run("AddToGroup(M)", func() error { _, err := svc.AddToGroup(emailsM, "g1"); return err })
|
||||
run("EmailsByGroup", func() error { _, err := svc.EmailsByGroup("g1"); return err })
|
||||
run("RenameGroup", func() error { _, err := svc.RenameGroup("g1", "g2"); return err })
|
||||
run("DeleteGroup", func() error { _, err := svc.DeleteGroup("g2"); return err })
|
||||
|
||||
run("ResetInboundTraffic", func() error { return inboundSvc.ResetInboundTraffic(ib.Id) })
|
||||
run("Inbound.ResetAllTraffics", func() error { return inboundSvc.ResetAllTraffics() })
|
||||
run("Client.ResetAllTraffics", func() error { _, err := svc.ResetAllTraffics(); return err })
|
||||
run("BulkResetTraffic(M)", func() error { _, err := svc.BulkResetTraffic(inboundSvc, emailsM); return err })
|
||||
|
||||
run("UpdateByEmail", func() error {
|
||||
upd := clients[n/3]
|
||||
upd.Comment = "touched"
|
||||
_, err := svc.UpdateByEmail(inboundSvc, upd.Email, upd)
|
||||
return err
|
||||
})
|
||||
run("AttachByEmail", func() error { _, err := svc.AttachByEmail(inboundSvc, emails[n/3], []int{ib2.Id}); return err })
|
||||
run("DetachByEmailMany", func() error { _, err := svc.DetachByEmailMany(inboundSvc, emails[n/3], []int{ib2.Id}); return err })
|
||||
|
||||
depEmails := emails[:1000]
|
||||
for _, batch := range chunkStrings(depEmails, sqlInChunk) {
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Update("down", int64(200)<<30).Error; err != nil {
|
||||
t.Fatalf("mark depleted: %v", err)
|
||||
}
|
||||
}
|
||||
run("DelDepleted(1k)", func() error { _, _, err := svc.DelDepleted(inboundSvc); return err })
|
||||
|
||||
run("DelInbound(full)", func() error { _, err := inboundSvc.DelInbound(ib.Id); return err })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetClientTrafficByEmailABScale measures the GetClientTrafficByEmail change:
|
||||
// old path (GetClientByEmail, which parses the inbound's entire settings JSON to
|
||||
// find one client) vs new path (UUID/subId read from the indexed clients table).
|
||||
func TestGetClientTrafficByEmailABScale(t *testing.T) {
|
||||
if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
|
||||
t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
|
||||
}
|
||||
xuilogger.InitLogger(logging.ERROR)
|
||||
if err := database.InitDB(""); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
const reps = 10
|
||||
sizes := []int{50000, 100000, 200000}
|
||||
|
||||
oldImpl := func(email string) error {
|
||||
tr, client, err := inboundSvc.GetClientByEmail(email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tr != nil && client != nil {
|
||||
tr.UUID = client.ID
|
||||
tr.SubId = client.SubID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, n := range sizes {
|
||||
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
|
||||
db := database.GetDB()
|
||||
if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
|
||||
t.Fatalf("truncate: %v", err)
|
||||
}
|
||||
clients := makeScaleClients(n)
|
||||
ib := &model.Inbound{UserId: 1, Tag: fmt.Sprintf("ctbe-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)}
|
||||
if err := db.Create(ib).Error; err != nil {
|
||||
t.Fatalf("create inbound: %v", err)
|
||||
}
|
||||
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
|
||||
t.Fatalf("seed SyncInbound: %v", err)
|
||||
}
|
||||
seedClientTraffics(t, ib.Id, clients)
|
||||
db.Exec("ANALYZE")
|
||||
|
||||
targets := []string{clients[0].Email, clients[n/2].Email, clients[n-1].Email}
|
||||
|
||||
start := time.Now()
|
||||
for i := 0; i < reps; i++ {
|
||||
if _, err := inboundSvc.GetClientTrafficByEmail(targets[i%len(targets)]); err != nil {
|
||||
t.Fatalf("new GetClientTrafficByEmail: %v", err)
|
||||
}
|
||||
}
|
||||
newDur := time.Since(start) / reps
|
||||
|
||||
start = time.Now()
|
||||
for i := 0; i < reps; i++ {
|
||||
if err := oldImpl(targets[i%len(targets)]); err != nil {
|
||||
t.Fatalf("old GetClientTrafficByEmail: %v", err)
|
||||
}
|
||||
}
|
||||
oldDur := time.Since(start) / reps
|
||||
|
||||
t.Logf("N=%-7d new=%-9v old=%-9v speedup=%.0fx", n,
|
||||
newDur.Round(time.Microsecond), oldDur.Round(time.Millisecond),
|
||||
float64(oldDur)/float64(maxDur(newDur, time.Microsecond)))
|
||||
})
|
||||
}
|
||||
}
|
||||
149
web/service/bulk_traffic_test.go
Normal file
149
web/service/bulk_traffic_test.go
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/xray"
|
||||
)
|
||||
|
||||
func mkTraffic(t *testing.T, inboundId int, email string, up, down, total, expiry int64, enable bool) {
|
||||
t.Helper()
|
||||
row := xray.ClientTraffic{
|
||||
InboundId: inboundId,
|
||||
Email: email,
|
||||
Up: up,
|
||||
Down: down,
|
||||
Total: total,
|
||||
ExpiryTime: expiry,
|
||||
Enable: enable,
|
||||
}
|
||||
if err := database.GetDB().Create(&row).Error; err != nil {
|
||||
t.Fatalf("create traffic %s: %v", email, err)
|
||||
}
|
||||
}
|
||||
|
||||
func trafficOf(t *testing.T, email string) xray.ClientTraffic {
|
||||
t.Helper()
|
||||
var row xray.ClientTraffic
|
||||
if err := database.GetDB().Where("email = ?", email).First(&row).Error; err != nil {
|
||||
t.Fatalf("load traffic %s: %v", email, err)
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
func TestBulkResetTrafficZeroesUsageAndReenables(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
source := []model.Client{
|
||||
{Email: "alice@x", ID: "11111111-1111-1111-1111-111111111111", SubID: "sa", Enable: true},
|
||||
{Email: "bob@x", ID: "22222222-2222-2222-2222-222222222222", SubID: "sb", Enable: true},
|
||||
{Email: "carol@x", ID: "33333333-3333-3333-3333-333333333333", SubID: "sc", Enable: true},
|
||||
}
|
||||
ib := mkInbound(t, 21001, model.VLESS, clientsSettings(t, source))
|
||||
if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
|
||||
t.Fatalf("seed linkage: %v", err)
|
||||
}
|
||||
mkTraffic(t, ib.Id, "alice@x", 10, 20, 0, 0, false)
|
||||
mkTraffic(t, ib.Id, "bob@x", 5, 5, 0, 0, true)
|
||||
mkTraffic(t, ib.Id, "carol@x", 7, 0, 0, 0, true)
|
||||
|
||||
affected, err := svc.BulkResetTraffic(inboundSvc, []string{"alice@x", "bob@x"})
|
||||
if err != nil {
|
||||
t.Fatalf("BulkResetTraffic: %v", err)
|
||||
}
|
||||
if affected != 2 {
|
||||
t.Fatalf("expected 2 affected, got %d", affected)
|
||||
}
|
||||
|
||||
for _, e := range []string{"alice@x", "bob@x"} {
|
||||
tr := trafficOf(t, e)
|
||||
if tr.Up != 0 || tr.Down != 0 {
|
||||
t.Fatalf("%s: expected up/down 0, got up=%d down=%d", e, tr.Up, tr.Down)
|
||||
}
|
||||
if !tr.Enable {
|
||||
t.Fatalf("%s: expected re-enabled", e)
|
||||
}
|
||||
}
|
||||
|
||||
carol := trafficOf(t, "carol@x")
|
||||
if carol.Up != 7 {
|
||||
t.Fatalf("carol not in list should be untouched, got up=%d", carol.Up)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelDepletedRemovesOnlyDepleted(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
source := []model.Client{
|
||||
{Email: "alice@x", ID: "11111111-1111-1111-1111-111111111111", SubID: "sa", Enable: true},
|
||||
{Email: "bob@x", ID: "22222222-2222-2222-2222-222222222222", SubID: "sb", Enable: true},
|
||||
{Email: "carol@x", ID: "33333333-3333-3333-3333-333333333333", SubID: "sc", Enable: true},
|
||||
}
|
||||
ib := mkInbound(t, 21002, model.VLESS, clientsSettings(t, source))
|
||||
if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
|
||||
t.Fatalf("seed linkage: %v", err)
|
||||
}
|
||||
past := time.Now().Add(-time.Hour).UnixMilli()
|
||||
mkTraffic(t, ib.Id, "alice@x", 60, 60, 100, 0, true)
|
||||
mkTraffic(t, ib.Id, "bob@x", 10, 10, 100, 0, true)
|
||||
mkTraffic(t, ib.Id, "carol@x", 0, 0, 0, past, true)
|
||||
|
||||
deleted, _, err := svc.DelDepleted(inboundSvc)
|
||||
if err != nil {
|
||||
t.Fatalf("DelDepleted: %v", err)
|
||||
}
|
||||
if deleted != 2 {
|
||||
t.Fatalf("expected 2 deleted (alice traffic-depleted, carol expired), got %d", deleted)
|
||||
}
|
||||
|
||||
if _, err := svc.GetRecordByEmail(nil, "bob@x"); err != nil {
|
||||
t.Fatalf("bob should survive: %v", err)
|
||||
}
|
||||
for _, e := range []string{"alice@x", "carol@x"} {
|
||||
if _, err := svc.GetRecordByEmail(nil, e); err == nil {
|
||||
t.Fatalf("%s should be deleted", e)
|
||||
}
|
||||
}
|
||||
|
||||
reloaded, _ := inboundSvc.GetInbound(ib.Id)
|
||||
jsonClients, _ := inboundSvc.GetClients(reloaded)
|
||||
if len(jsonClients) != 1 || jsonClients[0].Email != "bob@x" {
|
||||
t.Fatalf("settings JSON should contain only bob, got %d clients", len(jsonClients))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetClientTrafficByEmailReadsClientsTable(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
source := []model.Client{
|
||||
{Email: "alice@x", ID: "11111111-1111-1111-1111-111111111111", SubID: "sa", Enable: true},
|
||||
}
|
||||
ib := mkInbound(t, 21003, model.VLESS, clientsSettings(t, source))
|
||||
if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
|
||||
t.Fatalf("seed linkage: %v", err)
|
||||
}
|
||||
mkTraffic(t, ib.Id, "alice@x", 1, 2, 0, 0, true)
|
||||
|
||||
tr, err := inboundSvc.GetClientTrafficByEmail("alice@x")
|
||||
if err != nil {
|
||||
t.Fatalf("GetClientTrafficByEmail: %v", err)
|
||||
}
|
||||
if tr == nil {
|
||||
t.Fatalf("expected traffic, got nil")
|
||||
}
|
||||
if tr.UUID != "11111111-1111-1111-1111-111111111111" {
|
||||
t.Fatalf("UUID not enriched from clients table, got %q", tr.UUID)
|
||||
}
|
||||
if tr.SubId != "sa" {
|
||||
t.Fatalf("SubId not enriched from clients table, got %q", tr.SubId)
|
||||
}
|
||||
}
|
||||
|
|
@ -124,19 +124,23 @@ func compactOrphans(db *gorm.DB, clients []any) []any {
|
|||
if len(emails) == 0 {
|
||||
return clients
|
||||
}
|
||||
var existingEmails []string
|
||||
if err := db.Model(&model.ClientRecord{}).Where("email IN ?", emails).Pluck("email", &existingEmails).Error; err != nil {
|
||||
existing := make(map[string]struct{}, len(emails))
|
||||
const orphanChunk = 400
|
||||
for start := 0; start < len(emails); start += orphanChunk {
|
||||
end := min(start+orphanChunk, len(emails))
|
||||
var found []string
|
||||
if err := db.Model(&model.ClientRecord{}).Where("email IN ?", emails[start:end]).Pluck("email", &found).Error; err != nil {
|
||||
logger.Warning("compactOrphans pluck:", err)
|
||||
return clients
|
||||
}
|
||||
if len(existingEmails) == len(emails) {
|
||||
return clients
|
||||
}
|
||||
existing := make(map[string]struct{}, len(existingEmails))
|
||||
for _, e := range existingEmails {
|
||||
for _, e := range found {
|
||||
existing[e] = struct{}{}
|
||||
}
|
||||
out := make([]any, 0, len(existingEmails))
|
||||
}
|
||||
if len(existing) == len(emails) {
|
||||
return clients
|
||||
}
|
||||
out := make([]any, 0, len(existing))
|
||||
for _, c := range clients {
|
||||
cm, ok := c.(map[string]any)
|
||||
if !ok {
|
||||
|
|
@ -170,6 +174,26 @@ func tombstoneClientEmail(email string) {
|
|||
}
|
||||
}
|
||||
|
||||
func tombstoneClientEmails(emails []string) {
|
||||
if len(emails) == 0 {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-deleteTombstoneTTL)
|
||||
recentlyDeletedMu.Lock()
|
||||
defer recentlyDeletedMu.Unlock()
|
||||
for _, email := range emails {
|
||||
if email != "" {
|
||||
recentlyDeleted[email] = now
|
||||
}
|
||||
}
|
||||
for e, ts := range recentlyDeleted {
|
||||
if ts.Before(cutoff) {
|
||||
delete(recentlyDeleted, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isClientEmailTombstoned(email string) bool {
|
||||
if email == "" {
|
||||
return false
|
||||
|
|
@ -196,25 +220,54 @@ func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.
|
|||
return err
|
||||
}
|
||||
|
||||
emails := make([]string, 0, len(clients))
|
||||
seen := make(map[string]struct{}, len(clients))
|
||||
for i := range clients {
|
||||
c := clients[i]
|
||||
email := strings.TrimSpace(c.Email)
|
||||
email := strings.TrimSpace(clients[i].Email)
|
||||
if email == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[email]; ok {
|
||||
continue
|
||||
}
|
||||
seen[email] = struct{}{}
|
||||
emails = append(emails, email)
|
||||
}
|
||||
|
||||
existing := make(map[string]*model.ClientRecord, len(emails))
|
||||
const selectChunk = 400
|
||||
for start := 0; start < len(emails); start += selectChunk {
|
||||
end := min(start+selectChunk, len(emails))
|
||||
var rows []model.ClientRecord
|
||||
if err := tx.Where("email IN ?", emails[start:end]).Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range rows {
|
||||
r := rows[i]
|
||||
existing[r.Email] = &r
|
||||
}
|
||||
}
|
||||
|
||||
idByEmail := make(map[string]int, len(emails))
|
||||
pending := make(map[string]*model.ClientRecord, len(emails))
|
||||
toCreate := make([]*model.ClientRecord, 0, len(emails))
|
||||
for i := range clients {
|
||||
email := strings.TrimSpace(clients[i].Email)
|
||||
if email == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
incoming := c.ToRecord()
|
||||
row := &model.ClientRecord{}
|
||||
err := tx.Where("email = ?", email).First(row).Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
incoming := clients[i].ToRecord()
|
||||
row, ok := existing[email]
|
||||
if !ok {
|
||||
if _, dup := pending[email]; !dup {
|
||||
pending[email] = incoming
|
||||
toCreate = append(toCreate, incoming)
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
if err := tx.Create(incoming).Error; err != nil {
|
||||
return err
|
||||
continue
|
||||
}
|
||||
row = incoming
|
||||
} else {
|
||||
|
||||
before := *row
|
||||
if incoming.UUID != "" {
|
||||
row.UUID = incoming.UUID
|
||||
}
|
||||
|
|
@ -247,6 +300,12 @@ func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.
|
|||
}
|
||||
preservedUpdatedAt := max(incoming.UpdatedAt, row.UpdatedAt)
|
||||
row.UpdatedAt = preservedUpdatedAt
|
||||
|
||||
idByEmail[email] = row.Id
|
||||
|
||||
if *row == before {
|
||||
continue
|
||||
}
|
||||
if err := tx.Save(row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -257,12 +316,38 @@ func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.
|
|||
}
|
||||
}
|
||||
|
||||
link := model.ClientInbound{
|
||||
ClientId: row.Id,
|
||||
InboundId: inboundId,
|
||||
FlowOverride: c.Flow,
|
||||
if len(toCreate) > 0 {
|
||||
if err := tx.CreateInBatches(toCreate, 200).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(&link).Error; err != nil {
|
||||
for _, rec := range toCreate {
|
||||
idByEmail[rec.Email] = rec.Id
|
||||
}
|
||||
}
|
||||
|
||||
links := make([]model.ClientInbound, 0, len(clients))
|
||||
linked := make(map[int]struct{}, len(clients))
|
||||
for i := range clients {
|
||||
email := strings.TrimSpace(clients[i].Email)
|
||||
if email == "" {
|
||||
continue
|
||||
}
|
||||
id, ok := idByEmail[email]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, dup := linked[id]; dup {
|
||||
continue
|
||||
}
|
||||
linked[id] = struct{}{}
|
||||
links = append(links, model.ClientInbound{
|
||||
ClientId: id,
|
||||
InboundId: inboundId,
|
||||
FlowOverride: clients[i].Flow,
|
||||
})
|
||||
}
|
||||
if len(links) > 0 {
|
||||
if err := tx.CreateInBatches(links, 200).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
|
@ -397,21 +482,27 @@ func (s *ClientService) List() ([]ClientWithAttachments, error) {
|
|||
}
|
||||
}
|
||||
|
||||
attachments := make(map[int][]int, len(rows))
|
||||
for _, batch := range chunkInts(clientIds, sqlInChunk) {
|
||||
var links []model.ClientInbound
|
||||
if err := db.Where("client_id IN ?", clientIds).Find(&links).Error; err != nil {
|
||||
if err := db.Where("client_id IN ?", batch).Find(&links).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attachments := make(map[int][]int, len(rows))
|
||||
for _, l := range links {
|
||||
attachments[l.ClientId] = append(attachments[l.ClientId], l.InboundId)
|
||||
}
|
||||
}
|
||||
|
||||
trafficByEmail := make(map[string]*xray.ClientTraffic, len(emails))
|
||||
if len(emails) > 0 {
|
||||
var stats []xray.ClientTraffic
|
||||
if err := db.Where("email IN ?", emails).Find(&stats).Error; err != nil {
|
||||
for _, batch := range chunkStrings(emails, sqlInChunk) {
|
||||
var batchStats []xray.ClientTraffic
|
||||
if err := db.Where("email IN ?", batch).Find(&batchStats).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats = append(stats, batchStats...)
|
||||
}
|
||||
for i := range stats {
|
||||
trafficByEmail[stats[i].Email] = &stats[i]
|
||||
}
|
||||
|
|
@ -634,7 +725,7 @@ func applyShadowsocksClientMethod(clients []any, settings map[string]any) {
|
|||
}
|
||||
}
|
||||
|
||||
func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model.Client) (bool, error) {
|
||||
func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model.Client, inboundFilter ...int) (bool, error) {
|
||||
existing, err := s.GetByID(id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
|
|
@ -643,6 +734,19 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
|
|||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if len(inboundFilter) > 0 {
|
||||
allow := make(map[int]struct{}, len(inboundFilter))
|
||||
for _, fid := range inboundFilter {
|
||||
allow[fid] = struct{}{}
|
||||
}
|
||||
filtered := inboundIds[:0:0]
|
||||
for _, ibId := range inboundIds {
|
||||
if _, ok := allow[ibId]; ok {
|
||||
filtered = append(filtered, ibId)
|
||||
}
|
||||
}
|
||||
inboundIds = filtered
|
||||
}
|
||||
|
||||
if strings.TrimSpace(updated.Email) == "" {
|
||||
return false, common.NewError("client email is required")
|
||||
|
|
@ -1170,13 +1274,25 @@ func (s *ClientService) delInboundClients(inboundSvc *InboundService, inboundId
|
|||
}
|
||||
oldInbound.Settings = string(newSettings)
|
||||
|
||||
var sharedSet map[string]bool
|
||||
if !keepTraffic {
|
||||
removedEmails := make([]string, 0, len(removed))
|
||||
for _, r := range removed {
|
||||
if r.email != "" {
|
||||
removedEmails = append(removedEmails, r.email)
|
||||
}
|
||||
}
|
||||
var sharedErr error
|
||||
sharedSet, sharedErr = inboundSvc.emailsUsedByOtherInbounds(removedEmails, inboundId)
|
||||
if sharedErr != nil {
|
||||
return false, sharedErr
|
||||
}
|
||||
}
|
||||
|
||||
needRestart := false
|
||||
for _, r := range removed {
|
||||
email := r.email
|
||||
emailShared, err := inboundSvc.emailUsedByOtherInbounds(email, inboundId)
|
||||
if err != nil {
|
||||
return needRestart, err
|
||||
}
|
||||
emailShared := sharedSet[strings.ToLower(strings.TrimSpace(email))]
|
||||
if !emailShared && !keepTraffic {
|
||||
if err := inboundSvc.DelClientIPs(db, email); err != nil {
|
||||
logger.Error("Error in delete client IPs")
|
||||
|
|
@ -1317,7 +1433,7 @@ func (s *ClientService) findInboundIdsByClientEmail(email string) ([]int, error)
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ClientService) UpdateByEmail(inboundSvc *InboundService, email string, updated model.Client) (bool, error) {
|
||||
func (s *ClientService) UpdateByEmail(inboundSvc *InboundService, email string, updated model.Client, inboundFilter ...int) (bool, error) {
|
||||
if email == "" {
|
||||
return false, common.NewError("client email is required")
|
||||
}
|
||||
|
|
@ -1325,7 +1441,7 @@ func (s *ClientService) UpdateByEmail(inboundSvc *InboundService, email string,
|
|||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return s.Update(inboundSvc, rec.Id, updated)
|
||||
return s.Update(inboundSvc, rec.Id, updated, inboundFilter...)
|
||||
}
|
||||
|
||||
func (s *ClientService) ResetTrafficByEmail(inboundSvc *InboundService, email string) (bool, error) {
|
||||
|
|
@ -1631,14 +1747,43 @@ func (s *ClientService) BulkResetTraffic(inboundSvc *InboundService, emails []st
|
|||
if len(emails) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
count := 0
|
||||
for _, email := range emails {
|
||||
if _, err := s.ResetTrafficByEmail(inboundSvc, email); err != nil {
|
||||
return count, err
|
||||
seen := map[string]struct{}{}
|
||||
cleanEmails := make([]string, 0, len(emails))
|
||||
for _, e := range emails {
|
||||
e = strings.TrimSpace(e)
|
||||
if e == "" {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if _, ok := seen[e]; ok {
|
||||
continue
|
||||
}
|
||||
return count, nil
|
||||
seen[e] = struct{}{}
|
||||
cleanEmails = append(cleanEmails, e)
|
||||
}
|
||||
if len(cleanEmails) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
affected := 0
|
||||
err := submitTrafficWrite(func() error {
|
||||
db := database.GetDB()
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
for _, batch := range chunkStrings(cleanEmails, sqlInChunk) {
|
||||
res := tx.Model(xray.ClientTraffic{}).
|
||||
Where("email IN ?", batch).
|
||||
Updates(map[string]any{"enable": true, "up": 0, "down": 0})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
affected += int(res.RowsAffected)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return affected, nil
|
||||
}
|
||||
|
||||
func (s *ClientService) CreateGroup(name string) error {
|
||||
|
|
@ -1710,9 +1855,13 @@ func (s *ClientService) AddToGroup(emails []string, group string) (int, error) {
|
|||
}
|
||||
|
||||
var records []model.ClientRecord
|
||||
if err := db.Where("email IN ?", emails).Find(&records).Error; err != nil {
|
||||
for _, batch := range chunkStrings(emails, sqlInChunk) {
|
||||
var rows []model.ClientRecord
|
||||
if err := db.Where("email IN ?", batch).Find(&rows).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
records = append(records, rows...)
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
|
@ -1722,22 +1871,34 @@ func (s *ClientService) AddToGroup(emails []string, group string) (int, error) {
|
|||
}
|
||||
|
||||
tx := db.Begin()
|
||||
for _, batch := range chunkStrings(affectedEmails, sqlInChunk) {
|
||||
if err := tx.Model(&model.ClientRecord{}).
|
||||
Where("email IN ?", affectedEmails).
|
||||
Where("email IN ?", batch).
|
||||
UpdateColumn("group_name", group).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
var inboundIDs []int
|
||||
inboundIDSeen := make(map[int]struct{})
|
||||
for _, batch := range chunkStrings(affectedEmails, sqlInChunk) {
|
||||
var ids []int
|
||||
if err := tx.Table("client_inbounds").
|
||||
Joins("JOIN clients ON clients.id = client_inbounds.client_id").
|
||||
Where("clients.email IN ?", affectedEmails).
|
||||
Where("clients.email IN ?", batch).
|
||||
Distinct("client_inbounds.inbound_id").
|
||||
Pluck("inbound_id", &inboundIDs).Error; err != nil {
|
||||
Pluck("inbound_id", &ids).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return 0, err
|
||||
}
|
||||
for _, id := range ids {
|
||||
if _, ok := inboundIDSeen[id]; !ok {
|
||||
inboundIDSeen[id] = struct{}{}
|
||||
inboundIDs = append(inboundIDs, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emailSet := make(map[string]struct{}, len(affectedEmails))
|
||||
for _, e := range affectedEmails {
|
||||
|
|
@ -1828,14 +1989,24 @@ func (s *ClientService) replaceGroupValue(oldName, newName string) (int, error)
|
|||
}
|
||||
|
||||
var inboundIDs []int
|
||||
inboundIDSeen := make(map[int]struct{})
|
||||
for _, batch := range chunkStrings(affectedEmails, sqlInChunk) {
|
||||
var ids []int
|
||||
if err := tx.Table("client_inbounds").
|
||||
Joins("JOIN clients ON clients.id = client_inbounds.client_id").
|
||||
Where("clients.email IN ?", affectedEmails).
|
||||
Where("clients.email IN ?", batch).
|
||||
Distinct("client_inbounds.inbound_id").
|
||||
Pluck("inbound_id", &inboundIDs).Error; err != nil {
|
||||
Pluck("inbound_id", &ids).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return 0, err
|
||||
}
|
||||
for _, id := range ids {
|
||||
if _, ok := inboundIDSeen[id]; !ok {
|
||||
inboundIDSeen[id] = struct{}{}
|
||||
inboundIDs = append(inboundIDs, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, ibID := range inboundIDs {
|
||||
var ib model.Inbound
|
||||
|
|
@ -2304,9 +2475,13 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
|
|||
db := database.GetDB()
|
||||
|
||||
var records []model.ClientRecord
|
||||
if err := db.Where("email IN ?", cleanEmails).Find(&records).Error; err != nil {
|
||||
for _, batch := range chunkStrings(cleanEmails, sqlInChunk) {
|
||||
var rows []model.ClientRecord
|
||||
if err := db.Where("email IN ?", batch).Find(&rows).Error; err != nil {
|
||||
return result, false, err
|
||||
}
|
||||
records = append(records, rows...)
|
||||
}
|
||||
recordsByEmail := make(map[string]*model.ClientRecord, len(records))
|
||||
for i := range records {
|
||||
recordsByEmail[records[i].Email] = &records[i]
|
||||
|
|
@ -2381,9 +2556,13 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
|
|||
}
|
||||
|
||||
var mappings []model.ClientInbound
|
||||
if err := db.Where("client_id IN ?", plannedIds).Find(&mappings).Error; err != nil {
|
||||
for _, batch := range chunkInts(plannedIds, sqlInChunk) {
|
||||
var rows []model.ClientInbound
|
||||
if err := db.Where("client_id IN ?", batch).Find(&rows).Error; err != nil {
|
||||
return result, false, err
|
||||
}
|
||||
mappings = append(mappings, rows...)
|
||||
}
|
||||
emailsByInbound := map[int][]string{}
|
||||
for _, m := range mappings {
|
||||
email, ok := recordIdToEmail[m.ClientId]
|
||||
|
|
@ -2570,20 +2749,22 @@ func (s *ClientService) bulkAdjustInboundClients(
|
|||
}
|
||||
|
||||
db := database.GetDB()
|
||||
if err := db.Save(oldInbound).Error; err != nil {
|
||||
txErr := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Save(oldInbound).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
finalClients, gcErr := inboundSvc.GetClients(oldInbound)
|
||||
if gcErr != nil {
|
||||
return gcErr
|
||||
}
|
||||
return s.SyncInbound(tx, inboundId, finalClients)
|
||||
})
|
||||
if txErr != nil {
|
||||
for email := range foundEmails {
|
||||
if _, skip := res.perEmailSkipped[email]; !skip {
|
||||
res.perEmailSkipped[email] = err.Error()
|
||||
res.perEmailSkipped[email] = txErr.Error()
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
finalClients, gcErr := inboundSvc.GetClients(oldInbound)
|
||||
if gcErr == nil {
|
||||
if syncErr := s.SyncInbound(db, inboundId, finalClients); syncErr != nil {
|
||||
logger.Warning("bulkAdjust SyncInbound:", syncErr)
|
||||
}
|
||||
}
|
||||
|
||||
return res
|
||||
|
|
@ -2601,6 +2782,8 @@ type BulkDeleteReport struct {
|
|||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
const sqlInChunk = 400
|
||||
|
||||
// BulkDelete removes every client in the list in one optimized pass.
|
||||
// Instead of running the full single-delete pipeline N times (which would
|
||||
// re-read, re-parse, and re-write each inbound's settings JSON for every
|
||||
|
|
@ -2631,14 +2814,20 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string,
|
|||
db := database.GetDB()
|
||||
|
||||
var records []model.ClientRecord
|
||||
if err := db.Where("email IN ?", cleanEmails).Find(&records).Error; err != nil {
|
||||
for _, batch := range chunkStrings(cleanEmails, sqlInChunk) {
|
||||
var rows []model.ClientRecord
|
||||
if err := db.Where("email IN ?", batch).Find(&rows).Error; err != nil {
|
||||
return result, false, err
|
||||
}
|
||||
records = append(records, rows...)
|
||||
}
|
||||
recordsByEmail := make(map[string]*model.ClientRecord, len(records))
|
||||
tombstoneEmails := make([]string, 0, len(records))
|
||||
for i := range records {
|
||||
recordsByEmail[records[i].Email] = &records[i]
|
||||
tombstoneClientEmail(records[i].Email)
|
||||
tombstoneEmails = append(tombstoneEmails, records[i].Email)
|
||||
}
|
||||
tombstoneClientEmails(tombstoneEmails)
|
||||
|
||||
skippedReasons := map[string]string{}
|
||||
for _, email := range cleanEmails {
|
||||
|
|
@ -2657,9 +2846,13 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string,
|
|||
emailsByInbound := map[int][]string{}
|
||||
if len(clientIds) > 0 {
|
||||
var mappings []model.ClientInbound
|
||||
if err := db.Where("client_id IN ?", clientIds).Find(&mappings).Error; err != nil {
|
||||
for _, batch := range chunkInts(clientIds, sqlInChunk) {
|
||||
var rows []model.ClientInbound
|
||||
if err := db.Where("client_id IN ?", batch).Find(&rows).Error; err != nil {
|
||||
return result, false, err
|
||||
}
|
||||
mappings = append(mappings, rows...)
|
||||
}
|
||||
for _, m := range mappings {
|
||||
email, ok := recordIdToEmail[m.ClientId]
|
||||
if !ok {
|
||||
|
|
@ -2693,21 +2886,27 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string,
|
|||
}
|
||||
|
||||
if len(successIds) > 0 {
|
||||
if err := db.Where("client_id IN ?", successIds).Delete(&model.ClientInbound{}).Error; err != nil {
|
||||
for _, batch := range chunkInts(successIds, sqlInChunk) {
|
||||
if err := db.Where("client_id IN ?", batch).Delete(&model.ClientInbound{}).Error; err != nil {
|
||||
return result, needRestart, err
|
||||
}
|
||||
}
|
||||
if !keepTraffic && len(successEmails) > 0 {
|
||||
if err := db.Where("email IN ?", successEmails).Delete(&xray.ClientTraffic{}).Error; err != nil {
|
||||
for _, batch := range chunkStrings(successEmails, sqlInChunk) {
|
||||
if err := db.Where("email IN ?", batch).Delete(&xray.ClientTraffic{}).Error; err != nil {
|
||||
return result, needRestart, err
|
||||
}
|
||||
if err := db.Where("client_email IN ?", successEmails).Delete(&model.InboundClientIps{}).Error; err != nil {
|
||||
if err := db.Where("client_email IN ?", batch).Delete(&model.InboundClientIps{}).Error; err != nil {
|
||||
return result, needRestart, err
|
||||
}
|
||||
}
|
||||
if err := db.Where("id IN ?", successIds).Delete(&model.ClientRecord{}).Error; err != nil {
|
||||
}
|
||||
for _, batch := range chunkInts(successIds, sqlInChunk) {
|
||||
if err := db.Where("id IN ?", batch).Delete(&model.ClientRecord{}).Error; err != nil {
|
||||
return result, needRestart, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.Deleted = len(successEmails)
|
||||
for email, reason := range skippedReasons {
|
||||
|
|
@ -2835,9 +3034,10 @@ func (s *ClientService) bulkDelInboundClients(
|
|||
Email string
|
||||
Enable bool
|
||||
}
|
||||
for _, batch := range chunkStrings(foundList, sqlInChunk) {
|
||||
var rows []trafficRow
|
||||
if err := db.Model(xray.ClientTraffic{}).
|
||||
Where("email IN ?", foundList).
|
||||
Where("email IN ?", batch).
|
||||
Select("email, enable").
|
||||
Scan(&rows).Error; err == nil {
|
||||
for _, r := range rows {
|
||||
|
|
@ -2845,28 +3045,41 @@ func (s *ClientService) bulkDelInboundClients(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for email := range foundEmails {
|
||||
shared, sharedErr := inboundSvc.emailUsedByOtherInbounds(email, inboundId)
|
||||
var sharedSet map[string]bool
|
||||
if !keepTraffic {
|
||||
var sharedErr error
|
||||
sharedSet, sharedErr = inboundSvc.emailsUsedByOtherInbounds(foundList, inboundId)
|
||||
if sharedErr != nil {
|
||||
for email := range foundEmails {
|
||||
res.perEmailSkipped[email] = sharedErr.Error()
|
||||
delete(foundEmails, email)
|
||||
continue
|
||||
}
|
||||
if shared || keepTraffic {
|
||||
continue
|
||||
return res
|
||||
}
|
||||
if delErr := inboundSvc.DelClientIPs(db, email); delErr != nil {
|
||||
}
|
||||
if !keepTraffic {
|
||||
purge := make([]string, 0, len(foundEmails))
|
||||
for email := range foundEmails {
|
||||
if !sharedSet[strings.ToLower(strings.TrimSpace(email))] {
|
||||
purge = append(purge, email)
|
||||
}
|
||||
}
|
||||
if len(purge) > 0 {
|
||||
if delErr := inboundSvc.delClientIPsByEmails(db, purge); delErr != nil {
|
||||
logger.Error("Error in delete client IPs")
|
||||
for _, email := range purge {
|
||||
res.perEmailSkipped[email] = delErr.Error()
|
||||
delete(foundEmails, email)
|
||||
continue
|
||||
}
|
||||
if delErr := inboundSvc.DelClientStat(db, email); delErr != nil {
|
||||
} else if delErr := inboundSvc.delClientStatsByEmails(db, purge); delErr != nil {
|
||||
logger.Error("Delete stats Data Error")
|
||||
for _, email := range purge {
|
||||
res.perEmailSkipped[email] = delErr.Error()
|
||||
delete(foundEmails, email)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2907,21 +3120,22 @@ func (s *ClientService) bulkDelInboundClients(
|
|||
}
|
||||
}
|
||||
|
||||
if err := db.Save(oldInbound).Error; err != nil {
|
||||
for email := range foundEmails {
|
||||
if _, skip := res.perEmailSkipped[email]; !skip {
|
||||
res.perEmailSkipped[email] = err.Error()
|
||||
txErr := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Save(oldInbound).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
finalClients, err := inboundSvc.GetClients(oldInbound)
|
||||
if err != nil {
|
||||
return res
|
||||
return err
|
||||
}
|
||||
return s.SyncInbound(tx, inboundId, finalClients)
|
||||
})
|
||||
if txErr != nil {
|
||||
for email := range foundEmails {
|
||||
if _, skip := res.perEmailSkipped[email]; !skip {
|
||||
res.perEmailSkipped[email] = txErr.Error()
|
||||
}
|
||||
}
|
||||
if err := s.SyncInbound(db, inboundId, finalClients); err != nil {
|
||||
return res
|
||||
}
|
||||
|
||||
return res
|
||||
|
|
@ -2938,28 +3152,201 @@ type BulkCreateReport struct {
|
|||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// BulkCreate iterates payloads sequentially. Each item is the same shape
|
||||
// the single-create endpoint accepts, so callers can submit a heterogeneous
|
||||
// list (different inboundIds, plans, etc.) in one round-trip.
|
||||
func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []ClientCreatePayload) (BulkCreateResult, bool, error) {
|
||||
result := BulkCreateResult{}
|
||||
needRestart := false
|
||||
for i := range payloads {
|
||||
p := payloads[i]
|
||||
email := strings.TrimSpace(p.Client.Email)
|
||||
nr, err := s.Create(inboundSvc, &p)
|
||||
if err != nil {
|
||||
if email == "" {
|
||||
if len(payloads) == 0 {
|
||||
return result, false, nil
|
||||
}
|
||||
|
||||
skip := func(email, reason string) {
|
||||
if strings.TrimSpace(email) == "" {
|
||||
email = "(missing email)"
|
||||
}
|
||||
result.Skipped = append(result.Skipped, BulkCreateReport{Email: email, Reason: err.Error()})
|
||||
result.Skipped = append(result.Skipped, BulkCreateReport{Email: email, Reason: reason})
|
||||
}
|
||||
|
||||
emailSubIDs, err := inboundSvc.getAllEmailSubIDs()
|
||||
if err != nil {
|
||||
emailSubIDs = nil
|
||||
}
|
||||
|
||||
type prepared struct {
|
||||
client model.Client
|
||||
inboundIds []int
|
||||
}
|
||||
prep := make([]prepared, 0, len(payloads))
|
||||
emails := make([]string, 0, len(payloads))
|
||||
subIDs := make([]string, 0, len(payloads))
|
||||
seenEmail := make(map[string]struct{}, len(payloads))
|
||||
seenSubID := make(map[string]string, len(payloads))
|
||||
|
||||
for i := range payloads {
|
||||
client := payloads[i].Client
|
||||
email := strings.TrimSpace(client.Email)
|
||||
if email == "" {
|
||||
skip("", "client email is required")
|
||||
continue
|
||||
}
|
||||
if nr {
|
||||
if verr := validateClientEmail(email); verr != nil {
|
||||
skip(email, verr.Error())
|
||||
continue
|
||||
}
|
||||
if verr := validateClientSubID(client.SubID); verr != nil {
|
||||
skip(email, verr.Error())
|
||||
continue
|
||||
}
|
||||
if len(payloads[i].InboundIds) == 0 {
|
||||
skip(email, "at least one inbound is required")
|
||||
continue
|
||||
}
|
||||
|
||||
client.Email = email
|
||||
if client.SubID == "" {
|
||||
client.SubID = uuid.NewString()
|
||||
}
|
||||
if !client.Enable {
|
||||
client.Enable = true
|
||||
}
|
||||
now := time.Now().UnixMilli()
|
||||
if client.CreatedAt == 0 {
|
||||
client.CreatedAt = now
|
||||
}
|
||||
client.UpdatedAt = now
|
||||
|
||||
le := strings.ToLower(email)
|
||||
if _, dup := seenEmail[le]; dup {
|
||||
skip(email, "email already in use: "+email)
|
||||
continue
|
||||
}
|
||||
if owner, ok := seenSubID[client.SubID]; ok && owner != le {
|
||||
skip(email, "subId already in use: "+client.SubID)
|
||||
continue
|
||||
}
|
||||
seenEmail[le] = struct{}{}
|
||||
seenSubID[client.SubID] = le
|
||||
|
||||
prep = append(prep, prepared{client: client, inboundIds: payloads[i].InboundIds})
|
||||
emails = append(emails, email)
|
||||
subIDs = append(subIDs, client.SubID)
|
||||
}
|
||||
|
||||
if len(prep) == 0 {
|
||||
return result, false, nil
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
const lookupChunk = 400
|
||||
existingEmailSub := make(map[string]string, len(emails))
|
||||
for start := 0; start < len(emails); start += lookupChunk {
|
||||
end := min(start+lookupChunk, len(emails))
|
||||
var rows []model.ClientRecord
|
||||
if e := db.Where("email IN ?", emails[start:end]).Find(&rows).Error; e != nil {
|
||||
return result, false, e
|
||||
}
|
||||
for i := range rows {
|
||||
existingEmailSub[strings.ToLower(rows[i].Email)] = rows[i].SubID
|
||||
}
|
||||
}
|
||||
existingSubOwner := make(map[string]string, len(subIDs))
|
||||
for start := 0; start < len(subIDs); start += lookupChunk {
|
||||
end := min(start+lookupChunk, len(subIDs))
|
||||
var rows []model.ClientRecord
|
||||
if e := db.Where("sub_id IN ?", subIDs[start:end]).Find(&rows).Error; e != nil {
|
||||
return result, false, e
|
||||
}
|
||||
for i := range rows {
|
||||
existingSubOwner[rows[i].SubID] = strings.ToLower(rows[i].Email)
|
||||
}
|
||||
}
|
||||
|
||||
inboundCache := make(map[int]*model.Inbound)
|
||||
getIb := func(id int) (*model.Inbound, error) {
|
||||
if ib, ok := inboundCache[id]; ok {
|
||||
return ib, nil
|
||||
}
|
||||
ib, e := inboundSvc.GetInbound(id)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
inboundCache[id] = ib
|
||||
return ib, nil
|
||||
}
|
||||
|
||||
byInbound := make(map[int][]model.Client)
|
||||
idxByInbound := make(map[int][]int)
|
||||
inboundOrder := make([]int, 0)
|
||||
failed := make([]bool, len(prep))
|
||||
reason := make([]string, len(prep))
|
||||
|
||||
for idx := range prep {
|
||||
le := strings.ToLower(prep[idx].client.Email)
|
||||
if existSub, ok := existingEmailSub[le]; ok && existSub != prep[idx].client.SubID {
|
||||
failed[idx] = true
|
||||
reason[idx] = "email already in use: " + prep[idx].client.Email
|
||||
continue
|
||||
}
|
||||
if owner, ok := existingSubOwner[prep[idx].client.SubID]; ok && owner != le {
|
||||
failed[idx] = true
|
||||
reason[idx] = "subId already in use: " + prep[idx].client.SubID
|
||||
continue
|
||||
}
|
||||
|
||||
ok := true
|
||||
for _, ibId := range prep[idx].inboundIds {
|
||||
ib, e := getIb(ibId)
|
||||
if e != nil {
|
||||
failed[idx] = true
|
||||
reason[idx] = e.Error()
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
if e := s.fillProtocolDefaults(&prep[idx].client, ib); e != nil {
|
||||
failed[idx] = true
|
||||
reason[idx] = e.Error()
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, ibId := range prep[idx].inboundIds {
|
||||
ib, _ := getIb(ibId)
|
||||
if _, seen := byInbound[ibId]; !seen {
|
||||
inboundOrder = append(inboundOrder, ibId)
|
||||
}
|
||||
byInbound[ibId] = append(byInbound[ibId], clientWithInboundFlow(prep[idx].client, ib))
|
||||
idxByInbound[ibId] = append(idxByInbound[ibId], idx)
|
||||
}
|
||||
}
|
||||
|
||||
needRestart := false
|
||||
for _, ibId := range inboundOrder {
|
||||
payload, e := json.Marshal(map[string][]model.Client{"clients": byInbound[ibId]})
|
||||
if e == nil {
|
||||
var nr bool
|
||||
nr, e = s.addInboundClient(inboundSvc, &model.Inbound{Id: ibId, Settings: string(payload)}, emailSubIDs)
|
||||
if e == nil && nr {
|
||||
needRestart = true
|
||||
}
|
||||
}
|
||||
if e != nil {
|
||||
for _, idx := range idxByInbound[ibId] {
|
||||
failed[idx] = true
|
||||
if reason[idx] == "" {
|
||||
reason[idx] = e.Error()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for idx := range prep {
|
||||
if failed[idx] {
|
||||
skip(prep[idx].client.Email, reason[idx])
|
||||
} else {
|
||||
result.Created++
|
||||
}
|
||||
}
|
||||
return result, needRestart, nil
|
||||
}
|
||||
|
||||
|
|
@ -2976,33 +3363,27 @@ func (s *ClientService) DelDepleted(inboundSvc *InboundService) (int, bool, erro
|
|||
return 0, false, nil
|
||||
}
|
||||
|
||||
emails := make(map[string]struct{}, len(rows))
|
||||
seen := make(map[string]struct{}, len(rows))
|
||||
emails := make([]string, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
if r.Email != "" {
|
||||
emails[r.Email] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
needRestart := false
|
||||
deleted := 0
|
||||
for email := range emails {
|
||||
var rec model.ClientRecord
|
||||
if err := db.Where("email = ?", email).First(&rec).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
if r.Email == "" {
|
||||
continue
|
||||
}
|
||||
return deleted, needRestart, err
|
||||
if _, ok := seen[r.Email]; ok {
|
||||
continue
|
||||
}
|
||||
nr, err := s.Delete(inboundSvc, rec.Id, false)
|
||||
seen[r.Email] = struct{}{}
|
||||
emails = append(emails, r.Email)
|
||||
}
|
||||
if len(emails) == 0 {
|
||||
return 0, false, nil
|
||||
}
|
||||
|
||||
res, needRestart, err := s.BulkDelete(inboundSvc, emails, false)
|
||||
if err != nil {
|
||||
return deleted, needRestart, err
|
||||
return res.Deleted, needRestart, err
|
||||
}
|
||||
if nr {
|
||||
needRestart = true
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
return deleted, needRestart, nil
|
||||
return res.Deleted, needRestart, nil
|
||||
}
|
||||
|
||||
func (s *ClientService) ResetAllClientTraffics(inboundSvc *InboundService, id int) error {
|
||||
|
|
|
|||
|
|
@ -83,8 +83,17 @@ func (s *InboundService) enrichClientStats(db *gorm.DB, inbounds []*model.Inboun
|
|||
emails = append(emails, e)
|
||||
}
|
||||
var extra []xray.ClientTraffic
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", emails).Find(&extra).Error; err != nil {
|
||||
logger.Warning("enrichClientStats:", err)
|
||||
var loadErr error
|
||||
for _, batch := range chunkStrings(emails, sqlInChunk) {
|
||||
var page []xray.ClientTraffic
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Find(&page).Error; err != nil {
|
||||
loadErr = err
|
||||
break
|
||||
}
|
||||
extra = append(extra, page...)
|
||||
}
|
||||
if loadErr != nil {
|
||||
logger.Warning("enrichClientStats:", loadErr)
|
||||
} else {
|
||||
byEmail := make(map[string]xray.ClientTraffic, len(extra))
|
||||
for _, st := range extra {
|
||||
|
|
@ -438,6 +447,37 @@ func (s *InboundService) emailUsedByOtherInbounds(email string, exceptInboundId
|
|||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (s *InboundService) emailsUsedByOtherInbounds(emails []string, exceptInboundId int) (map[string]bool, error) {
|
||||
shared := make(map[string]bool, len(emails))
|
||||
want := make(map[string]struct{}, len(emails))
|
||||
for _, e := range emails {
|
||||
e = strings.ToLower(strings.TrimSpace(e))
|
||||
if e != "" {
|
||||
want[e] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(want) == 0 {
|
||||
return shared, nil
|
||||
}
|
||||
db := database.GetDB()
|
||||
var rows []string
|
||||
query := fmt.Sprintf(
|
||||
"SELECT DISTINCT LOWER(%s) %s WHERE inbounds.id != ?",
|
||||
database.JSONFieldText("client.value", "email"),
|
||||
database.JSONClientsFromInbound(),
|
||||
)
|
||||
if err := db.Raw(query, exceptInboundId).Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, e := range rows {
|
||||
e = strings.ToLower(strings.TrimSpace(e))
|
||||
if _, ok := want[e]; ok {
|
||||
shared[e] = true
|
||||
}
|
||||
}
|
||||
return shared, nil
|
||||
}
|
||||
|
||||
// normalizeStreamSettings clears StreamSettings for protocols that don't use it.
|
||||
// Only vmess, vless, trojan, shadowsocks, and hysteria protocols use streamSettings.
|
||||
func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) {
|
||||
|
|
@ -1575,12 +1615,19 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
|||
structuralChange = true
|
||||
}
|
||||
|
||||
// Only allow the node to disable a client (cs.Enable=false), never
|
||||
// to re-enable one the panel has already disabled. A stale snapshot
|
||||
// from the node arriving after a central disable would otherwise
|
||||
// overwrite enable=false back to true, letting the client accumulate
|
||||
// far more traffic than their limit before being disabled again.
|
||||
enableExpr := "CASE WHEN ? = 0 THEN 0 ELSE enable END"
|
||||
if err := tx.Exec(
|
||||
fmt.Sprintf(
|
||||
`UPDATE client_traffics
|
||||
SET up = up + ?, down = down + ?, enable = ?, total = ?, expiry_time = ?, reset = ?,
|
||||
SET up = up + ?, down = down + ?, enable = %s, total = ?, expiry_time = ?, reset = ?,
|
||||
last_online = %s
|
||||
WHERE email = ?`,
|
||||
enableExpr,
|
||||
database.GreatestExpr("last_online", "?"),
|
||||
),
|
||||
deltaUp, deltaDown, cs.Enable, cs.Total, cs.ExpiryTime, cs.Reset,
|
||||
|
|
@ -2466,6 +2513,32 @@ func (s *InboundService) DelClientIPs(tx *gorm.DB, email string) error {
|
|||
return tx.Where("client_email = ?", email).Delete(model.InboundClientIps{}).Error
|
||||
}
|
||||
|
||||
func (s *InboundService) delClientStatsByEmails(tx *gorm.DB, emails []string) error {
|
||||
const chunk = 400
|
||||
for start := 0; start < len(emails); start += chunk {
|
||||
end := min(start+chunk, len(emails))
|
||||
batch := emails[start:end]
|
||||
if err := tx.Where("email IN ?", batch).Delete(xray.ClientTraffic{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("email IN ?", batch).Delete(&model.NodeClientTraffic{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *InboundService) delClientIPsByEmails(tx *gorm.DB, emails []string) error {
|
||||
const chunk = 400
|
||||
for start := 0; start < len(emails); start += chunk {
|
||||
end := min(start+chunk, len(emails))
|
||||
if err := tx.Where("client_email IN ?", emails[start:end]).Delete(model.InboundClientIps{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *InboundService) GetClientInboundByTrafficID(trafficId int) (traffic *xray.ClientTraffic, inbound *model.Inbound, err error) {
|
||||
db := database.GetDB()
|
||||
var traffics []*xray.ClientTraffic
|
||||
|
|
@ -3019,16 +3092,33 @@ func (s *InboundService) GetInboundsTrafficSummary() ([]InboundTrafficSummary, e
|
|||
}
|
||||
|
||||
func (s *InboundService) GetClientTrafficByEmail(email string) (traffic *xray.ClientTraffic, err error) {
|
||||
// Prefer retrieving along with client to reflect actual enabled state from inbound settings
|
||||
t, client, err := s.GetClientByEmail(email)
|
||||
db := database.GetDB()
|
||||
var traffics []*xray.ClientTraffic
|
||||
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).Find(&traffics).Error; err != nil {
|
||||
logger.Warningf("Error retrieving ClientTraffic with email %s: %v", email, err)
|
||||
return nil, err
|
||||
}
|
||||
if len(traffics) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
t := traffics[0]
|
||||
|
||||
if rec, rErr := s.clientService.GetRecordByEmail(db, email); rErr == nil && rec != nil {
|
||||
c := rec.ToClient()
|
||||
t.UUID = c.ID
|
||||
t.SubId = c.SubID
|
||||
return t, nil
|
||||
}
|
||||
|
||||
t2, client, err := s.GetClientByEmail(email)
|
||||
if err != nil {
|
||||
logger.Warningf("Error retrieving ClientTraffic with email %s: %v", email, err)
|
||||
return nil, err
|
||||
}
|
||||
if t != nil && client != nil {
|
||||
t.UUID = client.ID
|
||||
t.SubId = client.SubID
|
||||
return t, nil
|
||||
if t2 != nil && client != nil {
|
||||
t2.UUID = client.ID
|
||||
t2.SubId = client.SubID
|
||||
return t2, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
|
@ -3357,6 +3447,9 @@ func (s *InboundService) MigrateDB() {
|
|||
}
|
||||
|
||||
func (s *InboundService) GetOnlineClients() []string {
|
||||
if p == nil {
|
||||
return []string{}
|
||||
}
|
||||
return p.GetOnlineClients()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -79,10 +79,11 @@ var defaultValueMap = map[string]string{
|
|||
"subClashEnable": "false",
|
||||
"subClashPath": "/clash/",
|
||||
"subClashURI": "",
|
||||
"subJsonFragment": "",
|
||||
"subJsonNoises": "",
|
||||
"subClashEnableRouting": "false",
|
||||
"subClashRules": "",
|
||||
"subJsonMux": "",
|
||||
"subJsonRules": "",
|
||||
"subJsonFinalMask": "",
|
||||
"datepicker": "gregorian",
|
||||
"warp": "",
|
||||
"nord": "",
|
||||
|
|
@ -658,12 +659,12 @@ func (s *SettingService) GetSubClashURI() (string, error) {
|
|||
return s.getString("subClashURI")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSubJsonFragment() (string, error) {
|
||||
return s.getString("subJsonFragment")
|
||||
func (s *SettingService) GetSubClashEnableRouting() (bool, error) {
|
||||
return s.getBool("subClashEnableRouting")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSubJsonNoises() (string, error) {
|
||||
return s.getString("subJsonNoises")
|
||||
func (s *SettingService) GetSubClashRules() (string, error) {
|
||||
return s.getString("subClashRules")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSubJsonMux() (string, error) {
|
||||
|
|
@ -674,6 +675,10 @@ func (s *SettingService) GetSubJsonRules() (string, error) {
|
|||
return s.getString("subJsonRules")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSubJsonFinalMask() (string, error) {
|
||||
return s.getString("subJsonFinalMask")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetDatepicker() (string, error) {
|
||||
return s.getString("datepicker")
|
||||
}
|
||||
|
|
|
|||
431
web/service/sync_scale_postgres_test.go
Normal file
431
web/service/sync_scale_postgres_test.go
Normal file
|
|
@ -0,0 +1,431 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mhsanaei/3x-ui/v3/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func syncInboundOld(tx *gorm.DB, inboundId int, clients []model.Client) error {
|
||||
if tx == nil {
|
||||
tx = database.GetDB()
|
||||
}
|
||||
if err := tx.Where("inbound_id = ?", inboundId).Delete(&model.ClientInbound{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range clients {
|
||||
c := clients[i]
|
||||
email := strings.TrimSpace(c.Email)
|
||||
if email == "" {
|
||||
continue
|
||||
}
|
||||
incoming := c.ToRecord()
|
||||
row := &model.ClientRecord{}
|
||||
err := tx.Where("email = ?", email).First(row).Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
if err := tx.Create(incoming).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
row = incoming
|
||||
} else {
|
||||
row.Flow = incoming.Flow
|
||||
row.SubID = incoming.SubID
|
||||
row.LimitIP = incoming.LimitIP
|
||||
row.TotalGB = incoming.TotalGB
|
||||
row.ExpiryTime = incoming.ExpiryTime
|
||||
row.Enable = incoming.Enable
|
||||
row.TgID = incoming.TgID
|
||||
row.Comment = incoming.Comment
|
||||
row.Reset = incoming.Reset
|
||||
preservedUpdatedAt := max(incoming.UpdatedAt, row.UpdatedAt)
|
||||
row.UpdatedAt = preservedUpdatedAt
|
||||
if err := tx.Save(row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&model.ClientRecord{}).
|
||||
Where("id = ?", row.Id).
|
||||
UpdateColumn("updated_at", preservedUpdatedAt).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
link := model.ClientInbound{ClientId: row.Id, InboundId: inboundId, FlowOverride: c.Flow}
|
||||
if err := tx.Create(&link).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func makeScaleClients(n int) []model.Client {
|
||||
out := make([]model.Client, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out[i] = model.Client{
|
||||
ID: uuid.NewString(),
|
||||
Email: fmt.Sprintf("user-%07d@scale", i),
|
||||
SubID: fmt.Sprintf("sub-%07d", i),
|
||||
Enable: true,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestSyncInboundPostgresScale(t *testing.T) {
|
||||
if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
|
||||
t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
|
||||
}
|
||||
if err := database.InitDB(""); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
|
||||
svc := &ClientService{}
|
||||
sizes := []int{5000, 10000, 20000, 50000, 100000, 200000}
|
||||
|
||||
for _, n := range sizes {
|
||||
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
|
||||
db := database.GetDB()
|
||||
if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds RESTART IDENTITY CASCADE").Error; err != nil {
|
||||
t.Fatalf("truncate: %v", err)
|
||||
}
|
||||
|
||||
clients := makeScaleClients(n)
|
||||
ib := &model.Inbound{
|
||||
Tag: fmt.Sprintf("scale-%d", n),
|
||||
Enable: true,
|
||||
Port: 40000,
|
||||
Protocol: model.VLESS,
|
||||
Settings: clientsSettings(t, clients),
|
||||
}
|
||||
if err := db.Create(ib).Error; err != nil {
|
||||
t.Fatalf("create inbound: %v", err)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
|
||||
t.Fatalf("seed SyncInbound: %v", err)
|
||||
}
|
||||
seed := time.Since(start)
|
||||
|
||||
clients[n/2].Enable = !clients[n/2].Enable
|
||||
start = time.Now()
|
||||
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
|
||||
t.Fatalf("toggle SyncInbound (new): %v", err)
|
||||
}
|
||||
toggleNew := time.Since(start)
|
||||
|
||||
start = time.Now()
|
||||
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
|
||||
t.Fatalf("noop SyncInbound (new): %v", err)
|
||||
}
|
||||
noopNew := time.Since(start)
|
||||
|
||||
toggleOld := time.Duration(0)
|
||||
if n <= 10000 {
|
||||
clients[n/2].Enable = !clients[n/2].Enable
|
||||
start = time.Now()
|
||||
if err := syncInboundOld(db, ib.Id, clients); err != nil {
|
||||
t.Fatalf("toggle SyncInbound (old): %v", err)
|
||||
}
|
||||
toggleOld = time.Since(start)
|
||||
}
|
||||
|
||||
var linkCount, recCount int64
|
||||
db.Model(&model.ClientInbound{}).Where("inbound_id = ?", ib.Id).Count(&linkCount)
|
||||
db.Model(&model.ClientRecord{}).Count(&recCount)
|
||||
if int(linkCount) != n || int(recCount) != n {
|
||||
t.Fatalf("row mismatch: links=%d records=%d want %d", linkCount, recCount, n)
|
||||
}
|
||||
|
||||
oldStr, speedup := "skipped", ""
|
||||
if toggleOld > 0 {
|
||||
oldStr = toggleOld.Round(time.Millisecond).String()
|
||||
speedup = fmt.Sprintf(" speedup=%.0fx", float64(toggleOld)/float64(maxDur(toggleNew, time.Millisecond)))
|
||||
}
|
||||
t.Logf("N=%-7d seed=%-10v toggle_new=%-10v noop_new=%-10v toggle_old=%-10s%s",
|
||||
n, seed.Round(time.Millisecond), toggleNew.Round(time.Millisecond),
|
||||
noopNew.Round(time.Millisecond), oldStr, speedup)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func maxDur(d, floor time.Duration) time.Duration {
|
||||
if d < floor {
|
||||
return floor
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func TestAddDelClientPostgresScale(t *testing.T) {
|
||||
if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
|
||||
t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
|
||||
}
|
||||
if err := database.InitDB(""); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
sizes := []int{5000, 20000, 50000, 100000, 200000}
|
||||
|
||||
for _, n := range sizes {
|
||||
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
|
||||
db := database.GetDB()
|
||||
if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
|
||||
t.Fatalf("truncate: %v", err)
|
||||
}
|
||||
|
||||
clients := makeScaleClients(n)
|
||||
ib := &model.Inbound{
|
||||
Tag: fmt.Sprintf("adddel-%d", n),
|
||||
Enable: true,
|
||||
Port: 40000,
|
||||
Protocol: model.VLESS,
|
||||
Settings: clientsSettings(t, clients),
|
||||
}
|
||||
if err := db.Create(ib).Error; err != nil {
|
||||
t.Fatalf("create inbound: %v", err)
|
||||
}
|
||||
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
|
||||
t.Fatalf("seed SyncInbound: %v", err)
|
||||
}
|
||||
|
||||
newC := model.Client{
|
||||
ID: uuid.NewString(),
|
||||
Email: "added-client@scale",
|
||||
SubID: "added-sub",
|
||||
Enable: true,
|
||||
}
|
||||
addData := &model.Inbound{Id: ib.Id, Protocol: model.VLESS, Settings: clientsSettings(t, []model.Client{newC})}
|
||||
start := time.Now()
|
||||
if _, err := svc.AddInboundClient(inboundSvc, addData); err != nil {
|
||||
t.Fatalf("AddInboundClient: %v", err)
|
||||
}
|
||||
addDur := time.Since(start)
|
||||
|
||||
delId := clients[n/2].ID
|
||||
start = time.Now()
|
||||
if _, err := svc.DelInboundClient(inboundSvc, ib.Id, delId, false); err != nil {
|
||||
t.Fatalf("DelInboundClient: %v", err)
|
||||
}
|
||||
delDur := time.Since(start)
|
||||
|
||||
var recCount, linkCount int64
|
||||
db.Model(&model.ClientRecord{}).Count(&recCount)
|
||||
db.Model(&model.ClientInbound{}).Where("inbound_id = ?", ib.Id).Count(&linkCount)
|
||||
|
||||
t.Logf("N=%-7d add=%-10v del=%-10v records=%d links=%d", n,
|
||||
addDur.Round(time.Millisecond), delDur.Round(time.Millisecond), recCount, linkCount)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupAndListPostgresScale(t *testing.T) {
|
||||
if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
|
||||
t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
|
||||
}
|
||||
if err := database.InitDB(""); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
|
||||
svc := &ClientService{}
|
||||
sizes := []int{5000, 100000}
|
||||
|
||||
for _, n := range sizes {
|
||||
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
|
||||
db := database.GetDB()
|
||||
if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
|
||||
t.Fatalf("truncate: %v", err)
|
||||
}
|
||||
clients := makeScaleClients(n)
|
||||
ib := &model.Inbound{Tag: fmt.Sprintf("grp-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)}
|
||||
if err := db.Create(ib).Error; err != nil {
|
||||
t.Fatalf("create inbound: %v", err)
|
||||
}
|
||||
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
|
||||
t.Fatalf("seed SyncInbound: %v", err)
|
||||
}
|
||||
db.Exec("ANALYZE")
|
||||
emails := make([]string, n)
|
||||
for i := 0; i < n; i++ {
|
||||
emails[i] = clients[i].Email
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
if _, err := svc.AddToGroup(emails, "benchgroup"); err != nil {
|
||||
t.Fatalf("AddToGroup: %v", err)
|
||||
}
|
||||
addDur := time.Since(start)
|
||||
|
||||
start = time.Now()
|
||||
if _, err := svc.RemoveFromGroup(emails); err != nil {
|
||||
t.Fatalf("RemoveFromGroup: %v", err)
|
||||
}
|
||||
rmDur := time.Since(start)
|
||||
|
||||
start = time.Now()
|
||||
list, err := svc.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
listDur := time.Since(start)
|
||||
if len(list) != n {
|
||||
t.Fatalf("List returned %d, want %d", len(list), n)
|
||||
}
|
||||
|
||||
t.Logf("N=%-7d bulkAdd=%-9v bulkRemove=%-9v list=%-9v", n,
|
||||
addDur.Round(time.Millisecond), rmDur.Round(time.Millisecond), listDur.Round(time.Millisecond))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelAllClientsPostgresScale(t *testing.T) {
|
||||
if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
|
||||
t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
|
||||
}
|
||||
if err := database.InitDB(""); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
sizes := []int{5000, 50000, 100000}
|
||||
|
||||
for _, n := range sizes {
|
||||
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
|
||||
db := database.GetDB()
|
||||
if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
|
||||
t.Fatalf("truncate: %v", err)
|
||||
}
|
||||
clients := makeScaleClients(n)
|
||||
ib := &model.Inbound{Tag: fmt.Sprintf("delall-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)}
|
||||
if err := db.Create(ib).Error; err != nil {
|
||||
t.Fatalf("create inbound: %v", err)
|
||||
}
|
||||
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
|
||||
t.Fatalf("seed SyncInbound: %v", err)
|
||||
}
|
||||
|
||||
emails, err := inboundSvc.EmailsByInbound(ib.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("EmailsByInbound: %v", err)
|
||||
}
|
||||
start := time.Now()
|
||||
res, _, err := svc.BulkDelete(inboundSvc, emails, false)
|
||||
if err != nil {
|
||||
t.Fatalf("BulkDelete: %v", err)
|
||||
}
|
||||
dur := time.Since(start)
|
||||
|
||||
var recCount, linkCount int64
|
||||
db.Model(&model.ClientRecord{}).Count(&recCount)
|
||||
db.Model(&model.ClientInbound{}).Where("inbound_id = ?", ib.Id).Count(&linkCount)
|
||||
if recCount != 0 || linkCount != 0 {
|
||||
t.Fatalf("after delAll: records=%d links=%d want 0/0", recCount, linkCount)
|
||||
}
|
||||
t.Logf("N=%-7d delAllClients=%-10v deleted=%d", n, dur.Round(time.Millisecond), res.Deleted)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkOpsPostgresScale(t *testing.T) {
|
||||
if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
|
||||
t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
|
||||
}
|
||||
if err := database.InitDB(""); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
sizes := []int{5000, 20000, 50000, 100000}
|
||||
const m = 2000
|
||||
|
||||
for _, n := range sizes {
|
||||
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
|
||||
db := database.GetDB()
|
||||
if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
|
||||
t.Fatalf("truncate: %v", err)
|
||||
}
|
||||
|
||||
clients := makeScaleClients(n)
|
||||
exp := time.Now().AddDate(1, 0, 0).UnixMilli()
|
||||
for i := range clients {
|
||||
clients[i].ExpiryTime = exp
|
||||
clients[i].TotalGB = 100 << 30
|
||||
}
|
||||
ib := &model.Inbound{Tag: fmt.Sprintf("bulk-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)}
|
||||
if err := db.Create(ib).Error; err != nil {
|
||||
t.Fatalf("create inbound: %v", err)
|
||||
}
|
||||
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
|
||||
t.Fatalf("seed SyncInbound: %v", err)
|
||||
}
|
||||
ib2 := &model.Inbound{Tag: fmt.Sprintf("bulk2-%d", n), Enable: true, Port: 40001, Protocol: model.VLESS, Settings: `{"clients":[]}`}
|
||||
if err := db.Create(ib2).Error; err != nil {
|
||||
t.Fatalf("create inbound2: %v", err)
|
||||
}
|
||||
|
||||
emailsM := make([]string, m)
|
||||
for i := 0; i < m; i++ {
|
||||
emailsM[i] = clients[i].Email
|
||||
}
|
||||
|
||||
t0 := time.Now()
|
||||
if _, _, err := svc.BulkAdjust(inboundSvc, emailsM, 7, 1<<30); err != nil {
|
||||
t.Fatalf("BulkAdjust: %v", err)
|
||||
}
|
||||
adjustDur := time.Since(t0)
|
||||
|
||||
t0 = time.Now()
|
||||
if _, _, err := svc.BulkAttach(inboundSvc, emailsM, []int{ib2.Id}); err != nil {
|
||||
t.Fatalf("BulkAttach: %v", err)
|
||||
}
|
||||
attachDur := time.Since(t0)
|
||||
|
||||
t0 = time.Now()
|
||||
if _, _, err := svc.BulkDetach(inboundSvc, emailsM, []int{ib2.Id}); err != nil {
|
||||
t.Fatalf("BulkDetach: %v", err)
|
||||
}
|
||||
detachDur := time.Since(t0)
|
||||
|
||||
payloads := make([]ClientCreatePayload, m)
|
||||
for i := 0; i < m; i++ {
|
||||
payloads[i] = ClientCreatePayload{
|
||||
Client: model.Client{ID: uuid.NewString(), Email: fmt.Sprintf("bulknew-%07d@scale", i), SubID: fmt.Sprintf("bnsub-%07d", i), Enable: true},
|
||||
InboundIds: []int{ib.Id},
|
||||
}
|
||||
}
|
||||
t0 = time.Now()
|
||||
if _, _, err := svc.BulkCreate(inboundSvc, payloads); err != nil {
|
||||
t.Fatalf("BulkCreate: %v", err)
|
||||
}
|
||||
createDur := time.Since(t0)
|
||||
|
||||
t0 = time.Now()
|
||||
if _, _, err := svc.BulkDelete(inboundSvc, emailsM, false); err != nil {
|
||||
t.Fatalf("BulkDelete: %v", err)
|
||||
}
|
||||
deleteDur := time.Since(t0)
|
||||
|
||||
t.Logf("N=%-6d M=%d adjust=%-9v attach=%-9v detach=%-9v create=%-9v delete=%-9v", n, m,
|
||||
adjustDur.Round(time.Millisecond), attachDur.Round(time.Millisecond), detachDur.Round(time.Millisecond),
|
||||
createDur.Round(time.Millisecond), deleteDur.Round(time.Millisecond))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -495,6 +495,10 @@ func (t *Tgbot) OnReceive() {
|
|||
}, th.TextEqual(t.I18nBot("tgbot.buttons.closeKeyboard")))
|
||||
|
||||
h.HandleMessage(func(ctx *th.Context, message telego.Message) error {
|
||||
if !t.isCommandForCurrentBot(&message) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use goroutine with worker pool for concurrent command processing
|
||||
go func() {
|
||||
messageWorkerPool <- struct{}{} // Acquire worker
|
||||
|
|
@ -684,6 +688,22 @@ func (t *Tgbot) answerCommand(message *telego.Message, chatId int64, isAdmin boo
|
|||
}
|
||||
}
|
||||
|
||||
func (t *Tgbot) isCommandForCurrentBot(message *telego.Message) bool {
|
||||
return isCommandForBot(message.Text, botUsername())
|
||||
}
|
||||
|
||||
func botUsername() string {
|
||||
if bot == nil {
|
||||
return ""
|
||||
}
|
||||
return bot.Username()
|
||||
}
|
||||
|
||||
func isCommandForBot(text string, username string) bool {
|
||||
_, commandUsername, _ := tu.ParseCommand(text)
|
||||
return commandUsername == "" || username == "" || strings.EqualFold(commandUsername, username)
|
||||
}
|
||||
|
||||
// sendResponse sends the response message based on the onlyMessage flag.
|
||||
func (t *Tgbot) sendResponse(chatId int64, msg string, onlyMessage, isAdmin bool) {
|
||||
if onlyMessage {
|
||||
|
|
|
|||
|
|
@ -99,3 +99,27 @@ func TestTgbotProxyDialerNoneWhenEmpty(t *testing.T) {
|
|||
t.Fatal("Dial must be nil when no proxy is configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCommandForBotAllowsUntargetedCommand(t *testing.T) {
|
||||
if !isCommandForBot("/status", "panel_bot") {
|
||||
t.Fatal("untargeted commands must remain accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCommandForBotAllowsMatchingUsername(t *testing.T) {
|
||||
if !isCommandForBot("/status@panel_bot", "Panel_Bot") {
|
||||
t.Fatal("commands targeted to this bot must be accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCommandForBotRejectsOtherUsername(t *testing.T) {
|
||||
if isCommandForBot("/status@other_bot", "panel_bot") {
|
||||
t.Fatal("commands targeted to another bot must be ignored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCommandForBotKeepsLegacyBehaviorWhenUsernameUnavailable(t *testing.T) {
|
||||
if !isCommandForBot("/status@panel_bot", "") {
|
||||
t.Fatal("commands must remain accepted when the current bot username is unavailable")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1004,6 +1004,10 @@
|
|||
"subEnableRoutingDesc": "إعداد عام لتمكين التوجيه (Routing) في عميل VPN. (فقط لـ Happ)",
|
||||
"subRoutingRules": "قواعد التوجيه",
|
||||
"subRoutingRulesDesc": "قواعد التوجيه العامة لعميل VPN. (فقط لـ Happ)",
|
||||
"subClashEnableRouting": "تفعيل التوجيه",
|
||||
"subClashEnableRoutingDesc": "تضمين قواعد توجيه Clash/Mihomo العامة في اشتراكات YAML المُنشأة.",
|
||||
"subClashRoutingRules": "قواعد التوجيه العامة",
|
||||
"subClashRoutingRulesDesc": "قواعد Clash/Mihomo التي تُضاف في بداية كل اشتراك YAML قبل MATCH,PROXY.",
|
||||
"subListen": "IP الاستماع",
|
||||
"subListenDesc": "عنوان IP لخدمة الاشتراك. (سيبه فاضي عشان يستمع على كل الـ IPs)",
|
||||
"subPort": "بورت الاستماع",
|
||||
|
|
@ -1070,6 +1074,8 @@
|
|||
"defaultIpLimit": "حد IP الافتراضي"
|
||||
},
|
||||
"subFormats": {
|
||||
"finalMask": "Final Mask",
|
||||
"finalMaskDesc": "أقنعة finalmask في xray (TCP/UDP) وضبط QUIC تُضاف إلى كل تدفق اشتراك JSON. يتطلب إصدار xray حديثًا على العميل.",
|
||||
"packets": "الحزم",
|
||||
"length": "الطول",
|
||||
"interval": "الفاصل",
|
||||
|
|
|
|||
|
|
@ -1004,6 +1004,10 @@
|
|||
"subEnableRoutingDesc": "Global setting to enable routing in the VPN client. (Only for Happ)",
|
||||
"subRoutingRules": "Routing rules",
|
||||
"subRoutingRulesDesc": "Global routing rules for the VPN client. (Only for Happ)",
|
||||
"subClashEnableRouting": "Enable routing",
|
||||
"subClashEnableRoutingDesc": "Include global Clash/Mihomo routing rules in generated YAML subscriptions.",
|
||||
"subClashRoutingRules": "Global routing rules",
|
||||
"subClashRoutingRulesDesc": "Default Clash/Mihomo rules prepended to every generated YAML subscription before MATCH,PROXY.",
|
||||
"subListen": "Listen IP",
|
||||
"subListenDesc": "The IP address for the subscription service. (leave blank to listen on all IPs)",
|
||||
"subPort": "Listen Port",
|
||||
|
|
@ -1070,6 +1074,8 @@
|
|||
"defaultIpLimit": "Default IP limit"
|
||||
},
|
||||
"subFormats": {
|
||||
"finalMask": "Final Mask",
|
||||
"finalMaskDesc": "xray finalmask masks (TCP/UDP) and QUIC tuning injected into every JSON subscription stream. Requires a recent xray client.",
|
||||
"packets": "Packets",
|
||||
"length": "Length",
|
||||
"interval": "Interval",
|
||||
|
|
|
|||
|
|
@ -1004,6 +1004,10 @@
|
|||
"subEnableRoutingDesc": "Configuración global para habilitar el enrutamiento en el cliente VPN. (Solo para Happ)",
|
||||
"subRoutingRules": "Reglas de enrutamiento",
|
||||
"subRoutingRulesDesc": "Reglas de enrutamiento globales para el cliente VPN. (Solo para Happ)",
|
||||
"subClashEnableRouting": "Habilitar enrutamiento",
|
||||
"subClashEnableRoutingDesc": "Incluir reglas globales de enrutamiento Clash/Mihomo en las suscripciones YAML generadas.",
|
||||
"subClashRoutingRules": "Reglas globales de enrutamiento",
|
||||
"subClashRoutingRulesDesc": "Reglas Clash/Mihomo agregadas al inicio de cada suscripción YAML antes de MATCH,PROXY.",
|
||||
"subListen": "Listening IP",
|
||||
"subListenDesc": "Dejar en blanco por defecto para monitorear todas las IPs.",
|
||||
"subPort": "Puerto de Suscripción",
|
||||
|
|
@ -1070,6 +1074,8 @@
|
|||
"defaultIpLimit": "Límite IP por defecto"
|
||||
},
|
||||
"subFormats": {
|
||||
"finalMask": "Final Mask",
|
||||
"finalMaskDesc": "Máscaras finalmask de xray (TCP/UDP) y ajustes QUIC inyectados en cada flujo de suscripción JSON. Requiere un cliente xray reciente.",
|
||||
"packets": "Paquetes",
|
||||
"length": "Longitud",
|
||||
"interval": "Intervalo",
|
||||
|
|
|
|||
|
|
@ -1004,6 +1004,10 @@
|
|||
"subEnableRoutingDesc": "تنظیمات سراسری برای فعالسازی مسیریابی در کلاینت VPN. (فقط برای Happ)",
|
||||
"subRoutingRules": "قوانین مسیریابی",
|
||||
"subRoutingRulesDesc": "قوانین مسیریابی سراسری برای کلاینت VPN. (فقط برای Happ)",
|
||||
"subClashEnableRouting": "فعالسازی مسیریابی",
|
||||
"subClashEnableRoutingDesc": "قوانین مسیریابی سراسری Clash/Mihomo را در اشتراکهای YAML تولیدشده وارد کن.",
|
||||
"subClashRoutingRules": "قوانین مسیریابی سراسری",
|
||||
"subClashRoutingRulesDesc": "قوانین Clash/Mihomo که پیش از MATCH,PROXY به ابتدای هر اشتراک YAML افزوده میشوند.",
|
||||
"subListen": "آدرس آیپی",
|
||||
"subListenDesc": "آدرس آیپی برای سرویس سابسکریپشن. برای گوش دادن بهتمام آیپیها خالیبگذارید",
|
||||
"subPort": "پورت",
|
||||
|
|
@ -1070,6 +1074,8 @@
|
|||
"defaultIpLimit": "محدودیت IP پیشفرض"
|
||||
},
|
||||
"subFormats": {
|
||||
"finalMask": "Final Mask",
|
||||
"finalMaskDesc": "ماسکهای finalmask ایکسری (TCP/UDP) و تنظیمات QUIC که داخل همهی stream های اشتراک JSON تزریق میشوند. به نسخهی جدید هستهی xray در کلاینت نیاز دارد.",
|
||||
"packets": "بستهها",
|
||||
"length": "طول",
|
||||
"interval": "بازه",
|
||||
|
|
|
|||
|
|
@ -1004,6 +1004,10 @@
|
|||
"subEnableRoutingDesc": "Pengaturan global untuk mengaktifkan perutean (routing) di klien VPN. (Hanya untuk Happ)",
|
||||
"subRoutingRules": "Aturan routing",
|
||||
"subRoutingRulesDesc": "Aturan routing global untuk klien VPN. (Hanya untuk Happ)",
|
||||
"subClashEnableRouting": "Aktifkan routing",
|
||||
"subClashEnableRoutingDesc": "Sertakan aturan routing global Clash/Mihomo dalam langganan YAML yang dibuat.",
|
||||
"subClashRoutingRules": "Aturan routing global",
|
||||
"subClashRoutingRulesDesc": "Aturan Clash/Mihomo yang ditambahkan di awal setiap langganan YAML sebelum MATCH,PROXY.",
|
||||
"subListen": "IP Pendengar",
|
||||
"subListenDesc": "Alamat IP untuk layanan langganan. (biarkan kosong untuk mendengarkan semua IP)",
|
||||
"subPort": "Port Pendengar",
|
||||
|
|
@ -1070,6 +1074,8 @@
|
|||
"defaultIpLimit": "Batas IP default"
|
||||
},
|
||||
"subFormats": {
|
||||
"finalMask": "Final Mask",
|
||||
"finalMaskDesc": "Mask finalmask xray (TCP/UDP) dan penyetelan QUIC yang disuntikkan ke setiap stream langganan JSON. Membutuhkan klien xray terbaru.",
|
||||
"packets": "Paket",
|
||||
"length": "Panjang",
|
||||
"interval": "Interval",
|
||||
|
|
|
|||
|
|
@ -1004,6 +1004,10 @@
|
|||
"subEnableRoutingDesc": "VPNクライアントでルーティングを有効にするためのグローバル設定。(Happのみ)",
|
||||
"subRoutingRules": "ルーティングルール",
|
||||
"subRoutingRulesDesc": "VPNクライアントのグローバルルーティングルール。(Happのみ)",
|
||||
"subClashEnableRouting": "ルーティングを有効化",
|
||||
"subClashEnableRoutingDesc": "生成されたYAMLサブスクリプションにClash/Mihomoのグローバルルーティングルールを含めます。",
|
||||
"subClashRoutingRules": "グローバルルーティングルール",
|
||||
"subClashRoutingRulesDesc": "各YAMLサブスクリプションのMATCH,PROXYより前に追加されるClash/Mihomoルール。",
|
||||
"subListen": "監視IP",
|
||||
"subListenDesc": "サブスクリプションサービスが監視するIPアドレス(空白にするとすべてのIPを監視)",
|
||||
"subPort": "監視ポート",
|
||||
|
|
@ -1070,6 +1074,8 @@
|
|||
"defaultIpLimit": "デフォルト IP 制限"
|
||||
},
|
||||
"subFormats": {
|
||||
"finalMask": "Final Mask",
|
||||
"finalMaskDesc": "すべての JSON サブスクリプションストリームに注入される xray finalmask マスク(TCP/UDP)と QUIC チューニング。新しい xray クライアントが必要です。",
|
||||
"packets": "パケット",
|
||||
"length": "長さ",
|
||||
"interval": "間隔",
|
||||
|
|
|
|||
|
|
@ -1004,6 +1004,10 @@
|
|||
"subEnableRoutingDesc": "Configuração global para habilitar o roteamento no cliente VPN. (Apenas para Happ)",
|
||||
"subRoutingRules": "Regras de roteamento",
|
||||
"subRoutingRulesDesc": "Regras de roteamento globais para o cliente VPN. (Apenas para Happ)",
|
||||
"subClashEnableRouting": "Ativar roteamento",
|
||||
"subClashEnableRoutingDesc": "Incluir regras globais de roteamento Clash/Mihomo nas assinaturas YAML geradas.",
|
||||
"subClashRoutingRules": "Regras globais de roteamento",
|
||||
"subClashRoutingRulesDesc": "Regras Clash/Mihomo adicionadas ao início de cada assinatura YAML antes de MATCH,PROXY.",
|
||||
"subListen": "IP de Escuta",
|
||||
"subListenDesc": "O endereço IP para o serviço de assinatura. (deixe em branco para escutar em todos os IPs)",
|
||||
"subPort": "Porta de Escuta",
|
||||
|
|
@ -1070,6 +1074,8 @@
|
|||
"defaultIpLimit": "Limite de IP padrão"
|
||||
},
|
||||
"subFormats": {
|
||||
"finalMask": "Final Mask",
|
||||
"finalMaskDesc": "Máscaras finalmask do xray (TCP/UDP) e ajustes QUIC injetados em cada fluxo de assinatura JSON. Requer um cliente xray recente.",
|
||||
"packets": "Pacotes",
|
||||
"length": "Comprimento",
|
||||
"interval": "Intervalo",
|
||||
|
|
|
|||
|
|
@ -1004,6 +1004,10 @@
|
|||
"subEnableRoutingDesc": "Глобальная настройка для включения маршрутизации в VPN-клиенте. (Только для Happ)",
|
||||
"subRoutingRules": "Правила маршрутизации",
|
||||
"subRoutingRulesDesc": "Глобальные правила маршрутизации для VPN-клиента. (Только для Happ)",
|
||||
"subClashEnableRouting": "Включить маршрутизацию",
|
||||
"subClashEnableRoutingDesc": "Добавлять глобальные правила маршрутизации Clash/Mihomo в сгенерированные YAML-подписки.",
|
||||
"subClashRoutingRules": "Глобальные правила маршрутизации",
|
||||
"subClashRoutingRulesDesc": "Правила Clash/Mihomo, добавляемые в начало каждой YAML-подписки перед MATCH,PROXY.",
|
||||
"subListen": "Прослушивание IP",
|
||||
"subListenDesc": "Оставьте пустым по умолчанию, чтобы отслеживать все IP-адреса",
|
||||
"subPort": "Порт подписки",
|
||||
|
|
@ -1070,6 +1074,8 @@
|
|||
"defaultIpLimit": "Лимит IP по умолчанию"
|
||||
},
|
||||
"subFormats": {
|
||||
"finalMask": "Final Mask",
|
||||
"finalMaskDesc": "Маски finalmask xray (TCP/UDP) и настройки QUIC, добавляемые в каждый поток JSON-подписки. Требуется свежая версия xray на клиенте.",
|
||||
"packets": "Пакеты",
|
||||
"length": "Длина",
|
||||
"interval": "Интервал",
|
||||
|
|
|
|||
|
|
@ -1004,6 +1004,10 @@
|
|||
"subEnableRoutingDesc": "VPN istemcisinde yönlendirmeyi etkinleştirmek için genel ayar. (Yalnızca Happ için)",
|
||||
"subRoutingRules": "Yönlendirme kuralları",
|
||||
"subRoutingRulesDesc": "VPN istemcisi için genel yönlendirme kuralları. (Yalnızca Happ için)",
|
||||
"subClashEnableRouting": "Yönlendirmeyi etkinleştir",
|
||||
"subClashEnableRoutingDesc": "Oluşturulan YAML aboneliklerine genel Clash/Mihomo yönlendirme kurallarını ekle.",
|
||||
"subClashRoutingRules": "Genel yönlendirme kuralları",
|
||||
"subClashRoutingRulesDesc": "Her YAML aboneliğinin başına MATCH,PROXY öncesinde eklenen Clash/Mihomo kuralları.",
|
||||
"subListen": "Dinleme IP",
|
||||
"subListenDesc": "Abonelik hizmeti için IP adresi. (tüm IP'leri dinlemek için boş bırakın)",
|
||||
"subPort": "Dinleme Portu",
|
||||
|
|
@ -1070,6 +1074,8 @@
|
|||
"defaultIpLimit": "Varsayılan IP limiti"
|
||||
},
|
||||
"subFormats": {
|
||||
"finalMask": "Final Mask",
|
||||
"finalMaskDesc": "Her JSON abonelik akışına eklenen xray finalmask maskeleri (TCP/UDP) ve QUIC ayarları. Güncel bir xray istemcisi gerektirir.",
|
||||
"packets": "Paketler",
|
||||
"length": "Uzunluk",
|
||||
"interval": "Aralık",
|
||||
|
|
|
|||
|
|
@ -1004,6 +1004,10 @@
|
|||
"subEnableRoutingDesc": "Глобальне налаштування для увімкнення маршрутизації у VPN-клієнті. (Тільки для Happ)",
|
||||
"subRoutingRules": "Правила маршрутизації",
|
||||
"subRoutingRulesDesc": "Глобальні правила маршрутизації для VPN-клієнта. (Тільки для Happ)",
|
||||
"subClashEnableRouting": "Увімкнути маршрутизацію",
|
||||
"subClashEnableRoutingDesc": "Додавати глобальні правила маршрутизації Clash/Mihomo до згенерованих YAML-підписок.",
|
||||
"subClashRoutingRules": "Глобальні правила маршрутизації",
|
||||
"subClashRoutingRulesDesc": "Правила Clash/Mihomo, що додаються на початок кожної YAML-підписки перед MATCH,PROXY.",
|
||||
"subListen": "Слухати IP",
|
||||
"subListenDesc": "IP-адреса для служби підписки. (залиште порожнім, щоб слухати всі IP-адреси)",
|
||||
"subPort": "Слухати порт",
|
||||
|
|
@ -1070,6 +1074,8 @@
|
|||
"defaultIpLimit": "Ліміт IP за замовч."
|
||||
},
|
||||
"subFormats": {
|
||||
"finalMask": "Final Mask",
|
||||
"finalMaskDesc": "Маски finalmask xray (TCP/UDP) і налаштування QUIC, що додаються до кожного потоку JSON-підписки. Потрібна свіжа версія xray на клієнті.",
|
||||
"packets": "Пакети",
|
||||
"length": "Довжина",
|
||||
"interval": "Інтервал",
|
||||
|
|
|
|||
|
|
@ -1004,6 +1004,10 @@
|
|||
"subEnableRoutingDesc": "Cài đặt toàn cục để bật định tuyến trong ứng dụng khách VPN. (Chỉ dành cho Happ)",
|
||||
"subRoutingRules": "Quy tắc định tuyến",
|
||||
"subRoutingRulesDesc": "Quy tắc định tuyến toàn cầu cho client VPN. (Chỉ dành cho Happ)",
|
||||
"subClashEnableRouting": "Bật định tuyến",
|
||||
"subClashEnableRoutingDesc": "Bao gồm quy tắc định tuyến Clash/Mihomo toàn cầu trong các đăng ký YAML được tạo.",
|
||||
"subClashRoutingRules": "Quy tắc định tuyến toàn cầu",
|
||||
"subClashRoutingRulesDesc": "Quy tắc Clash/Mihomo được thêm vào đầu mỗi đăng ký YAML trước MATCH,PROXY.",
|
||||
"subListen": "Listening IP",
|
||||
"subListenDesc": "Mặc định để trống để nghe tất cả các IP",
|
||||
"subPort": "Cổng gói đăng ký",
|
||||
|
|
@ -1070,6 +1074,8 @@
|
|||
"defaultIpLimit": "Giới hạn IP mặc định"
|
||||
},
|
||||
"subFormats": {
|
||||
"finalMask": "Final Mask",
|
||||
"finalMaskDesc": "Mask finalmask của xray (TCP/UDP) và tinh chỉnh QUIC được thêm vào mọi luồng đăng ký JSON. Yêu cầu client xray mới hơn.",
|
||||
"packets": "Gói",
|
||||
"length": "Độ dài",
|
||||
"interval": "Khoảng",
|
||||
|
|
|
|||
|
|
@ -1004,6 +1004,10 @@
|
|||
"subEnableRoutingDesc": "在 VPN 客户端中启用路由的全局设置。(僅限 Happ)",
|
||||
"subRoutingRules": "路由規則",
|
||||
"subRoutingRulesDesc": "VPN 用戶端的全域路由規則。(僅限 Happ)",
|
||||
"subClashEnableRouting": "启用路由",
|
||||
"subClashEnableRoutingDesc": "在生成的 YAML 订阅中包含 Clash/Mihomo 全局路由规则。",
|
||||
"subClashRoutingRules": "全局路由规则",
|
||||
"subClashRoutingRulesDesc": "添加到每个 YAML 订阅开头、MATCH,PROXY 之前的 Clash/Mihomo 规则。",
|
||||
"subListen": "监听 IP",
|
||||
"subListenDesc": "订阅服务监听的 IP 地址(留空表示监听所有 IP)",
|
||||
"subPort": "监听端口",
|
||||
|
|
@ -1070,6 +1074,8 @@
|
|||
"defaultIpLimit": "默认 IP 限制"
|
||||
},
|
||||
"subFormats": {
|
||||
"finalMask": "Final Mask",
|
||||
"finalMaskDesc": "注入到每个 JSON 订阅流的 xray finalmask 掩码(TCP/UDP)和 QUIC 调优。需要较新的 xray 客户端。",
|
||||
"packets": "数据包",
|
||||
"length": "长度",
|
||||
"interval": "间隔",
|
||||
|
|
|
|||
|
|
@ -1004,6 +1004,10 @@
|
|||
"subEnableRoutingDesc": "在 VPN 用戶端中啟用路由的全域設定。(僅限 Happ)",
|
||||
"subRoutingRules": "路由規則",
|
||||
"subRoutingRulesDesc": "VPN 用戶端的全域路由規則。(僅限 Happ)",
|
||||
"subClashEnableRouting": "啟用路由",
|
||||
"subClashEnableRoutingDesc": "在產生的 YAML 訂閱中包含 Clash/Mihomo 全域路由規則。",
|
||||
"subClashRoutingRules": "全域路由規則",
|
||||
"subClashRoutingRulesDesc": "加入到每個 YAML 訂閱開頭、MATCH,PROXY 之前的 Clash/Mihomo 規則。",
|
||||
"subListen": "監聽 IP",
|
||||
"subListenDesc": "訂閱服務監聽的 IP 地址(留空表示監聽所有 IP)",
|
||||
"subPort": "監聽埠",
|
||||
|
|
@ -1070,6 +1074,8 @@
|
|||
"defaultIpLimit": "預設 IP 限制"
|
||||
},
|
||||
"subFormats": {
|
||||
"finalMask": "Final Mask",
|
||||
"finalMaskDesc": "注入到每個 JSON 訂閱串流的 xray finalmask 遮罩(TCP/UDP)與 QUIC 調校。需要較新的 xray 用戶端。",
|
||||
"packets": "封包",
|
||||
"length": "長度",
|
||||
"interval": "間隔",
|
||||
|
|
|
|||
57
x-ui.sh
57
x-ui.sh
|
|
@ -1269,6 +1269,16 @@ ssl_cert_issue_main() {
|
|||
echo "Panel paths set for domain: $domain"
|
||||
echo " - Certificate File: $webCertFile"
|
||||
echo " - Private Key File: $webKeyFile"
|
||||
# Register the acme.sh install-cert hook so auto-renewal copies the
|
||||
# renewed cert to these paths and reloads the panel. Without it acme.sh
|
||||
# renews but never updates /root/cert, silently serving a stale cert.
|
||||
if command -v ~/.acme.sh/acme.sh &> /dev/null && ~/.acme.sh/acme.sh --list 2> /dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
|
||||
~/.acme.sh/acme.sh --installcert -d "${domain}" \
|
||||
--key-file "${webKeyFile}" \
|
||||
--fullchain-file "${webCertFile}" \
|
||||
--reloadcmd "x-ui restart" 2>&1 || true
|
||||
echo "Registered acme.sh auto-renewal hook for ${domain}."
|
||||
fi
|
||||
restart
|
||||
else
|
||||
echo "Certificate or private key not found for domain: $domain."
|
||||
|
|
@ -1448,8 +1458,8 @@ ssl_cert_issue_for_ip() {
|
|||
LOGE "Failed to issue certificate for IP: ${server_ip}"
|
||||
LOGE "Make sure port ${WebPort} is open and the server is accessible from the internet"
|
||||
# Cleanup acme.sh data for both IPv4 and IPv6 if specified
|
||||
rm -rf ~/.acme.sh/${server_ip} 2> /dev/null
|
||||
[[ -n "$ipv6_addr" ]] && rm -rf ~/.acme.sh/${ipv6_addr} 2> /dev/null
|
||||
rm -rf ~/.acme.sh/${server_ip} ~/.acme.sh/${server_ip}_ecc 2> /dev/null
|
||||
[[ -n "$ipv6_addr" ]] && rm -rf ~/.acme.sh/${ipv6_addr} ~/.acme.sh/${ipv6_addr}_ecc 2> /dev/null
|
||||
rm -rf ${certPath} 2> /dev/null
|
||||
return 1
|
||||
else
|
||||
|
|
@ -1468,8 +1478,8 @@ ssl_cert_issue_for_ip() {
|
|||
if [[ ! -f "${certPath}/fullchain.pem" || ! -f "${certPath}/privkey.pem" ]]; then
|
||||
LOGE "Certificate files not found after installation"
|
||||
# Cleanup acme.sh data for both IPv4 and IPv6 if specified
|
||||
rm -rf ~/.acme.sh/${server_ip} 2> /dev/null
|
||||
[[ -n "$ipv6_addr" ]] && rm -rf ~/.acme.sh/${ipv6_addr} 2> /dev/null
|
||||
rm -rf ~/.acme.sh/${server_ip} ~/.acme.sh/${server_ip}_ecc 2> /dev/null
|
||||
[[ -n "$ipv6_addr" ]] && rm -rf ~/.acme.sh/${ipv6_addr} ~/.acme.sh/${ipv6_addr}_ecc 2> /dev/null
|
||||
rm -rf ${certPath} 2> /dev/null
|
||||
return 1
|
||||
fi
|
||||
|
|
@ -1576,14 +1586,30 @@ ssl_cert_issue() {
|
|||
LOGD "Your domain is: ${domain}, checking it..."
|
||||
SSL_ISSUED_DOMAIN="${domain}"
|
||||
|
||||
# detect existing certificate and reuse it if present
|
||||
# detect existing certificate and reuse it only if its files are actually
|
||||
# present and non-empty. acme.sh stores ECC certs under ${domain}_ecc and RSA
|
||||
# certs under ${domain}; a failed issuance can leave a domain entry in --list
|
||||
# with no usable cert files, which must not be reused (it produces a 0-byte
|
||||
# fullchain.pem). Broken partial state is cleaned up so issuance can proceed.
|
||||
local cert_exists=0
|
||||
if ~/.acme.sh/acme.sh --list 2> /dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
|
||||
local acmeCertDir=""
|
||||
if [[ -s ~/.acme.sh/${domain}_ecc/fullchain.cer && -s ~/.acme.sh/${domain}_ecc/${domain}.key ]]; then
|
||||
acmeCertDir=~/.acme.sh/${domain}_ecc
|
||||
elif [[ -s ~/.acme.sh/${domain}/fullchain.cer && -s ~/.acme.sh/${domain}/${domain}.key ]]; then
|
||||
acmeCertDir=~/.acme.sh/${domain}
|
||||
fi
|
||||
if [[ -n "${acmeCertDir}" ]]; then
|
||||
cert_exists=1
|
||||
local certInfo=$(~/.acme.sh/acme.sh --list 2> /dev/null | grep -F "${domain}")
|
||||
LOGI "Existing certificate found for ${domain}, will reuse it."
|
||||
[[ -n "${certInfo}" ]] && LOGI "${certInfo}"
|
||||
else
|
||||
LOGW "Found incomplete acme.sh state for ${domain} (no valid certificate files); cleaning it up and re-issuing."
|
||||
rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
|
||||
fi
|
||||
fi
|
||||
if [[ ${cert_exists} -eq 0 ]]; then
|
||||
LOGI "Your domain is ready for issuing certificates now..."
|
||||
fi
|
||||
|
||||
|
|
@ -1611,7 +1637,7 @@ ssl_cert_issue() {
|
|||
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
|
||||
if [ $? -ne 0 ]; then
|
||||
LOGE "Issuing certificate failed, please check logs."
|
||||
rm -rf ~/.acme.sh/${domain}
|
||||
rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
|
||||
exit 1
|
||||
else
|
||||
LOGE "Issuing certificate succeeded, installing certificates..."
|
||||
|
|
@ -1664,7 +1690,7 @@ ssl_cert_issue() {
|
|||
else
|
||||
LOGE "Installing certificate failed, exiting."
|
||||
if [[ ${cert_exists} -eq 0 ]]; then
|
||||
rm -rf ~/.acme.sh/${domain}
|
||||
rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
|
@ -2248,6 +2274,18 @@ failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnect
|
|||
ignoreregex =
|
||||
EOF
|
||||
|
||||
# Ports to exempt from the ban so an over-limit proxy client can never lock
|
||||
# the administrator out of SSH or the panel. The ban still covers every other
|
||||
# TCP port (including all Xray inbounds), so IP-limit keeps working for inbounds
|
||||
# added later without regenerating these files.
|
||||
local ssh_ports
|
||||
ssh_ports=$(grep -oP '^[[:space:]]*Port[[:space:]]+\K[0-9]+' /etc/ssh/sshd_config 2>/dev/null | paste -sd, -)
|
||||
[[ -z "${ssh_ports}" ]] && ssh_ports="22"
|
||||
local panel_port
|
||||
panel_port=$(${xui_folder}/x-ui setting -show true 2>/dev/null | grep -Eo 'port: .+' | awk '{print $2}')
|
||||
local exempt_ports="${ssh_ports}"
|
||||
[[ -n "${panel_port}" ]] && exempt_ports="${exempt_ports},${panel_port}"
|
||||
|
||||
cat << EOF > /etc/fail2ban/action.d/3x-ipl.conf
|
||||
[INCLUDES]
|
||||
before = iptables-allports.conf
|
||||
|
|
@ -2263,16 +2301,17 @@ actionstop = <iptables> -D <chain> -p <protocol> -j f2b-<name>
|
|||
|
||||
actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]'
|
||||
|
||||
actionban = <iptables> -I f2b-<name> 1 -s <ip> -j <blocktype>
|
||||
actionban = <iptables> -I f2b-<name> 1 -s <ip> -p <protocol> -m multiport ! --dports <exemptports> -j <blocktype>
|
||||
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") BAN [Email] = <F-USER> [IP] = <ip> banned for <bantime> seconds." >> ${iplimit_banned_log_path}
|
||||
|
||||
actionunban = <iptables> -D f2b-<name> -s <ip> -j <blocktype>
|
||||
actionunban = <iptables> -D f2b-<name> -s <ip> -p <protocol> -m multiport ! --dports <exemptports> -j <blocktype>
|
||||
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> ${iplimit_banned_log_path}
|
||||
|
||||
[Init]
|
||||
name = default
|
||||
protocol = tcp
|
||||
chain = INPUT
|
||||
exemptports = ${exempt_ports}
|
||||
EOF
|
||||
|
||||
echo -e "${green}Ip Limit jail files created with a bantime of ${bantime} minutes.${plain}"
|
||||
|
|
|
|||
Loading…
Reference in a new issue