Merge branch 'main' into fix/disabled-client-stays-connected-on-remote-node

This commit is contained in:
Sanaei 2026-06-04 23:57:53 +02:00 committed by GitHub
commit 27829c8414
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 2275 additions and 592 deletions

View file

@ -27,6 +27,16 @@ failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnect
ignoreregex = ignoreregex =
EOF 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 cat > /etc/fail2ban/action.d/3x-ipl.conf << EOF
[INCLUDES] [INCLUDES]
before = iptables-allports.conf 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]' 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 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 echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> $LOG_FOLDER/3xipl-banned.log
[Init] [Init]
name = default name = default
protocol = tcp protocol = tcp
chain = INPUT chain = INPUT
exemptports = $EXEMPT_PORTS
EOF EOF
fail2ban-client -x start fail2ban-client -x start

View file

@ -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 // Empty the destination tables so the migration is idempotent: a fresh
// PostgreSQL DB already holds an auto-seeded admin (id=1) from any prior // 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 // panel start, and a partially-failed earlier run leaves rows behind. Either

View file

@ -17,7 +17,7 @@
"axios": "^1.17.0", "axios": "^1.17.0",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"dayjs": "^1.11.21", "dayjs": "^1.11.21",
"i18next": "^26.3.0", "i18next": "^26.3.1",
"otpauth": "^9.5.1", "otpauth": "^9.5.1",
"persian-calendar-suite": "^1.5.5", "persian-calendar-suite": "^1.5.5",
"qs": "^6.15.2", "qs": "^6.15.2",
@ -1934,9 +1934,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -1954,9 +1951,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -1974,9 +1968,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -1994,9 +1985,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -2014,9 +2002,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -2034,9 +2019,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -5087,9 +5069,9 @@
} }
}, },
"node_modules/i18next": { "node_modules/i18next": {
"version": "26.3.0", "version": "26.3.1",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.0.tgz", "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
"integrity": "sha512-gHSgGpUXVmuqE2El1W61DmxeyeTlFfZgdJRWMo9jScAn5pu7TuTuiccb1zh3E2J9hEBVGJ23+96x0ieBhfuIHA==", "integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
"funding": [ "funding": [
{ {
"type": "individual", "type": "individual",
@ -5615,9 +5597,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -5639,9 +5618,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -5663,9 +5639,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -5687,9 +5660,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [

View file

@ -29,7 +29,7 @@
"axios": "^1.17.0", "axios": "^1.17.0",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"dayjs": "^1.11.21", "dayjs": "^1.11.21",
"i18next": "^26.3.0", "i18next": "^26.3.1",
"otpauth": "^9.5.1", "otpauth": "^9.5.1",
"persian-calendar-suite": "^1.5.5", "persian-calendar-suite": "^1.5.5",
"qs": "^6.15.2", "qs": "^6.15.2",

View file

@ -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": { "/panel/api/server/getNewUUID": {
"get": { "get": {
"tags": [ "tags": [
@ -5761,7 +5791,7 @@
"tags": [ "tags": [
"Subscription Server" "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", "operationId": "get_clashPath_subid",
"parameters": [ "parameters": [
{ {

View file

@ -34,7 +34,9 @@ export interface AllSetting {
subAnnounce: string; subAnnounce: string;
subCertFile: string; subCertFile: string;
subClashEnable: boolean; subClashEnable: boolean;
subClashEnableRouting: boolean;
subClashPath: string; subClashPath: string;
subClashRules: string;
subClashURI: string; subClashURI: string;
subDomain: string; subDomain: string;
subEmailInRemark: boolean; subEmailInRemark: boolean;
@ -42,9 +44,8 @@ export interface AllSetting {
subEnableRouting: boolean; subEnableRouting: boolean;
subEncrypt: boolean; subEncrypt: boolean;
subJsonEnable: boolean; subJsonEnable: boolean;
subJsonFragment: string; subJsonFinalMask: string;
subJsonMux: string; subJsonMux: string;
subJsonNoises: string;
subJsonPath: string; subJsonPath: string;
subJsonRules: string; subJsonRules: string;
subJsonURI: string; subJsonURI: string;
@ -121,7 +122,9 @@ export interface AllSettingView {
subAnnounce: string; subAnnounce: string;
subCertFile: string; subCertFile: string;
subClashEnable: boolean; subClashEnable: boolean;
subClashEnableRouting: boolean;
subClashPath: string; subClashPath: string;
subClashRules: string;
subClashURI: string; subClashURI: string;
subDomain: string; subDomain: string;
subEmailInRemark: boolean; subEmailInRemark: boolean;
@ -129,9 +132,8 @@ export interface AllSettingView {
subEnableRouting: boolean; subEnableRouting: boolean;
subEncrypt: boolean; subEncrypt: boolean;
subJsonEnable: boolean; subJsonEnable: boolean;
subJsonFragment: string; subJsonFinalMask: string;
subJsonMux: string; subJsonMux: string;
subJsonNoises: string;
subJsonPath: string; subJsonPath: string;
subJsonRules: string; subJsonRules: string;
subJsonURI: string; subJsonURI: string;

View file

@ -36,7 +36,9 @@ export const AllSettingSchema = z.object({
subAnnounce: z.string(), subAnnounce: z.string(),
subCertFile: z.string(), subCertFile: z.string(),
subClashEnable: z.boolean(), subClashEnable: z.boolean(),
subClashEnableRouting: z.boolean(),
subClashPath: z.string(), subClashPath: z.string(),
subClashRules: z.string(),
subClashURI: z.string(), subClashURI: z.string(),
subDomain: z.string(), subDomain: z.string(),
subEmailInRemark: z.boolean(), subEmailInRemark: z.boolean(),
@ -44,9 +46,8 @@ export const AllSettingSchema = z.object({
subEnableRouting: z.boolean(), subEnableRouting: z.boolean(),
subEncrypt: z.boolean(), subEncrypt: z.boolean(),
subJsonEnable: z.boolean(), subJsonEnable: z.boolean(),
subJsonFragment: z.string(), subJsonFinalMask: z.string(),
subJsonMux: z.string(), subJsonMux: z.string(),
subJsonNoises: z.string(),
subJsonPath: z.string(), subJsonPath: z.string(),
subJsonRules: z.string(), subJsonRules: z.string(),
subJsonURI: z.string(), subJsonURI: z.string(),
@ -124,7 +125,9 @@ export const AllSettingViewSchema = z.object({
subAnnounce: z.string(), subAnnounce: z.string(),
subCertFile: z.string(), subCertFile: z.string(),
subClashEnable: z.boolean(), subClashEnable: z.boolean(),
subClashEnableRouting: z.boolean(),
subClashPath: z.string(), subClashPath: z.string(),
subClashRules: z.string(),
subClashURI: z.string(), subClashURI: z.string(),
subDomain: z.string(), subDomain: z.string(),
subEmailInRemark: z.boolean(), subEmailInRemark: z.boolean(),
@ -132,9 +135,8 @@ export const AllSettingViewSchema = z.object({
subEnableRouting: z.boolean(), subEnableRouting: z.boolean(),
subEncrypt: z.boolean(), subEncrypt: z.boolean(),
subJsonEnable: z.boolean(), subJsonEnable: z.boolean(),
subJsonFragment: z.string(), subJsonFinalMask: z.string(),
subJsonMux: z.string(), subJsonMux: z.string(),
subJsonNoises: z.string(),
subJsonPath: z.string(), subJsonPath: z.string(),
subJsonRules: z.string(), subJsonRules: z.string(),
subJsonURI: z.string(), subJsonURI: z.string(),

View file

@ -6,21 +6,15 @@ import type { NamePath } from 'antd/es/form/interface';
import { RandomUtil } from '@/utils'; import { RandomUtil } from '@/utils';
import { OutboundProtocols } from '@/schemas/primitives'; 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 { export interface FinalMaskFormProps {
name: NamePath; name: NamePath;
network: string; network: string;
protocol: string; protocol: string;
form: FormInstance; 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']; 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' }; 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 base = asPath(name);
const isHysteria = protocol === OutboundProtocols.Hysteria || protocol === 'hysteria'; const isHysteria = protocol === OutboundProtocols.Hysteria || protocol === 'hysteria';
const showTcp = TCP_NETWORKS.includes(network); const showTcp = showAll || TCP_NETWORKS.includes(network);
const showUdp = isHysteria || network === 'kcp'; const showUdp = showAll || isHysteria || network === 'kcp';
const showQuic = isHysteria || network === 'xhttp'; const showQuic = showAll || isHysteria || network === 'xhttp';
const quicParams = Form.useWatch([...base, 'quicParams'], { form, preserve: true }); const quicParams = Form.useWatch([...base, 'quicParams'], { form, preserve: true });
const hasQuicParams = quicParams != null; const hasQuicParams = quicParams != null;
@ -392,13 +386,13 @@ function UdpMaskItem({
const options = isHysteria const options = isHysteria
? [{ value: 'salamander', label: 'Salamander (Hysteria2)' }] ? [{ value: 'salamander', label: 'Salamander (Hysteria2)' }]
: [ : [
{ value: 'mkcp-legacy', label: 'mKCP Legacy' }, { value: 'mkcp-legacy', label: 'mKCP Legacy' },
{ value: 'xdns', label: 'xDNS' }, { value: 'xdns', label: 'xDNS' },
{ value: 'xicmp', label: 'xICMP' }, { value: 'xicmp', label: 'xICMP' },
{ value: 'realm', label: 'Realm' }, { value: 'realm', label: 'Realm' },
{ value: 'header-custom', label: 'Header Custom' }, { value: 'header-custom', label: 'Header Custom' },
{ value: 'noise', label: 'Noise' }, { value: 'noise', label: 'Noise' },
]; ];
return ( return (
<div> <div>

View file

@ -55,10 +55,11 @@ export class AllSetting {
subURI = ''; subURI = '';
subJsonURI = ''; subJsonURI = '';
subClashURI = ''; subClashURI = '';
subJsonFragment = ''; subClashEnableRouting = false;
subJsonNoises = ''; subClashRules = '';
subJsonMux = ''; subJsonMux = '';
subJsonRules = ''; subJsonRules = '';
subJsonFinalMask = '';
timeLocation = 'Local'; timeLocation = 'Local';

View file

@ -1114,7 +1114,7 @@ export const sections: readonly Section[] = [
{ {
method: 'GET', method: 'GET',
path: '/{clashPath}:subid', 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: [ params: [
{ name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' }, { name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' },
], ],

View file

@ -249,7 +249,7 @@ export default function ClientBulkAddModal({
)} )}
{form.emailMethod < 2 && ( {form.emailMethod < 2 && (
<Form.Item label={t('pages.clients.clientCount')}> <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> </Form.Item>
)} )}

View file

@ -71,6 +71,7 @@ import type { ClientFilters } from './filters';
import './ClientsPage.css'; import './ClientsPage.css';
const FILTER_STATE_KEY = 'clientsFilterState'; const FILTER_STATE_KEY = 'clientsFilterState';
const DISABLED_PAGE_SIZE = 200;
function UngroupIcon() { function UngroupIcon() {
return ( return (
@ -276,10 +277,7 @@ export default function ClientsPage() {
const activeCount = activeFilterCount(filters); const activeCount = activeFilterCount(filters);
useEffect(() => { useEffect(() => {
if (pageSize > 0) { setTablePageSize(pageSize > 0 ? pageSize : DISABLED_PAGE_SIZE);
setTablePageSize(pageSize);
}
}, [pageSize]); }, [pageSize]);
const onlineSet = useMemo(() => new Set(onlines || []), [onlines]); const onlineSet = useMemo(() => new Set(onlines || []), [onlines]);

View 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>
);
}

View file

@ -1,8 +1,6 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
Button,
Card,
Input, Input,
InputNumber, InputNumber,
Select, Select,
@ -10,19 +8,17 @@ import {
Tabs, Tabs,
} from 'antd'; } from 'antd';
import { import {
DeleteOutlined,
PartitionOutlined, PartitionOutlined,
PlusOutlined, RocketOutlined,
ScissorOutlined,
SendOutlined, SendOutlined,
SettingOutlined, SettingOutlined,
ThunderboltOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { AllSetting } from '@/models/setting'; import type { AllSetting } from '@/models/setting';
import { SettingListItem } from '@/components/ui'; import { SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel'; import { catTabLabel } from './catTabLabel';
import { sanitizePath, normalizePath } from './uriPath'; import { sanitizePath, normalizePath } from './uriPath';
import SubJsonFinalMaskForm from './SubJsonFinalMaskForm';
import './SubscriptionFormatsTab.css'; import './SubscriptionFormatsTab.css';
interface SubscriptionFormatsTabProps { interface SubscriptionFormatsTabProps {
@ -30,15 +26,6 @@ interface SubscriptionFormatsTabProps {
updateSetting: (patch: Partial<AllSetting>) => void; 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 = { const DEFAULT_MUX = {
enabled: true, enabled: true,
concurrency: 8, concurrency: 8,
@ -85,55 +72,9 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
const { t } = useTranslation(); const { t } = useTranslation();
const { isMobile } = useMediaQuery(); const { isMobile } = useMediaQuery();
const fragment = allSetting.subJsonFragment !== '';
const noisesEnabled = allSetting.subJsonNoises !== '';
const muxEnabled = allSetting.subJsonMux !== ''; const muxEnabled = allSetting.subJsonMux !== '';
const directEnabled = allSetting.subJsonRules !== ''; 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( const muxObj = useMemo(
() => (muxEnabled ? readJson<typeof DEFAULT_MUX>(allSetting.subJsonMux, DEFAULT_MUX) : DEFAULT_MUX), () => (muxEnabled ? readJson<typeof DEFAULT_MUX>(allSetting.subJsonMux, DEFAULT_MUX) : DEFAULT_MUX),
[allSetting.subJsonMux, muxEnabled], [allSetting.subJsonMux, muxEnabled],
@ -251,98 +192,19 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
}, },
{ {
key: '2', key: '2',
label: catTabLabel(<ScissorOutlined />, t('pages.settings.fragment'), isMobile), label: catTabLabel(<RocketOutlined />, t('pages.settings.subFormats.finalMask'), isMobile),
children: ( children: (
<> <>
<SettingListItem paddings="small" title={t('pages.settings.fragment')} description={t('pages.settings.fragmentDesc')}> <SettingListItem paddings="small" title={t('pages.settings.subFormats.finalMask')} description={t('pages.settings.subFormats.finalMaskDesc')} />
<Switch checked={fragment} onChange={setFragmentEnabled} /> <SubJsonFinalMaskForm
</SettingListItem> value={allSetting.subJsonFinalMask}
{fragment && ( onChange={(v) => updateSetting({ subJsonFinalMask: v })}
<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>
)}
</> </>
), ),
}, },
{ {
key: '3', 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), label: catTabLabel(<PartitionOutlined />, t('pages.settings.mux'), isMobile),
children: ( children: (
<> <>
@ -373,7 +235,7 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
), ),
}, },
{ {
key: '5', key: '4',
label: catTabLabel(<SendOutlined />, t('pages.settings.direct'), isMobile), label: catTabLabel(<SendOutlined />, t('pages.settings.direct'), isMobile),
children: ( children: (
<> <>

View file

@ -166,6 +166,20 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
<Input.TextArea value={allSetting.subRoutingRules} placeholder="happ://routing/add/..." <Input.TextArea value={allSetting.subRoutingRules} placeholder="happ://routing/add/..."
onChange={(e) => updateSetting({ subRoutingRules: e.target.value })} /> onChange={(e) => updateSetting({ subRoutingRules: e.target.value })} />
</SettingListItem> </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>
</> </>
), ),
}, },

View file

@ -182,7 +182,7 @@ export const ClientBulkAddFormSchema = z.object({
lastNum: z.number().int().min(1), lastNum: z.number().int().min(1),
emailPrefix: z.string(), emailPrefix: z.string(),
emailPostfix: z.string(), emailPostfix: z.string(),
quantity: z.number().int().min(1).max(100), quantity: z.number().int().min(1).max(1000),
subId: z.string(), subId: z.string(),
group: z.string(), group: z.string(),
comment: z.string(), comment: z.string(),

View file

@ -59,10 +59,11 @@ export const AllSettingSchema = z.object({
subURI: z.string().optional(), subURI: z.string().optional(),
subJsonURI: z.string().optional(), subJsonURI: z.string().optional(),
subClashURI: z.string().optional(), subClashURI: z.string().optional(),
subJsonFragment: z.string().optional(), subClashEnableRouting: z.boolean().optional(),
subJsonNoises: z.string().optional(), subClashRules: z.string().optional(),
subJsonMux: z.string().optional(), subJsonMux: z.string().optional(),
subJsonRules: z.string().optional(), subJsonRules: z.string().optional(),
subJsonFinalMask: z.string().optional(),
timeLocation: z.string().optional(), timeLocation: z.string().optional(),
ldapEnable: z.boolean().optional(), ldapEnable: z.boolean().optional(),
ldapHost: z.string().optional(), ldapHost: z.string().optional(),

View file

@ -858,13 +858,13 @@ export class LanguageManager {
}); });
if (LanguageManager.isSupportLanguage(lang)) { if (LanguageManager.isSupportLanguage(lang)) {
CookieManager.setCookie('lang', lang); CookieManager.setCookie('lang', lang, 365);
} else { } else {
CookieManager.setCookie('lang', 'en-US'); CookieManager.setCookie('lang', 'en-US', 365);
window.location.reload(); window.location.reload();
} }
} else { } else {
CookieManager.setCookie('lang', 'en-US'); CookieManager.setCookie('lang', 'en-US', 365);
window.location.reload(); window.location.reload();
} }
@ -875,7 +875,7 @@ export class LanguageManager {
if (!LanguageManager.isSupportLanguage(language)) { if (!LanguageManager.isSupportLanguage(language)) {
language = 'en-US'; language = 'en-US';
} }
CookieManager.setCookie('lang', language); CookieManager.setCookie('lang', language, 365);
window.location.reload(); window.location.reload();
} }

View file

@ -297,7 +297,7 @@ setup_ssl_certificate() {
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
echo -e "${yellow}Failed to issue certificate for ${domain}${plain}" 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}" 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 rm -rf "$certPath" 2> /dev/null
return 1 return 1
fi fi
@ -431,8 +431,8 @@ setup_ip_certificate() {
echo -e "${red}Failed to issue IP certificate${plain}" 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}" 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 # Cleanup acme.sh data for both IPv4 and IPv6 if specified
rm -rf ~/.acme.sh/${ipv4} 2> /dev/null rm -rf ~/.acme.sh/${ipv4} ~/.acme.sh/${ipv4}_ecc 2> /dev/null
[[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} 2> /dev/null [[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} ~/.acme.sh/${ipv6}_ecc 2> /dev/null
rm -rf ${certDir} 2> /dev/null rm -rf ${certDir} 2> /dev/null
return 1 return 1
fi fi
@ -451,8 +451,8 @@ setup_ip_certificate() {
if [[ ! -f "${certDir}/fullchain.pem" || ! -f "${certDir}/privkey.pem" ]]; then if [[ ! -f "${certDir}/fullchain.pem" || ! -f "${certDir}/privkey.pem" ]]; then
echo -e "${red}Certificate files not found after installation${plain}" echo -e "${red}Certificate files not found after installation${plain}"
# Cleanup acme.sh data for both IPv4 and IPv6 if specified # Cleanup acme.sh data for both IPv4 and IPv6 if specified
rm -rf ~/.acme.sh/${ipv4} 2> /dev/null rm -rf ~/.acme.sh/${ipv4} ~/.acme.sh/${ipv4}_ecc 2> /dev/null
[[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} 2> /dev/null [[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} ~/.acme.sh/${ipv6}_ecc 2> /dev/null
rm -rf ${certDir} 2> /dev/null rm -rf ${certDir} 2> /dev/null
return 1 return 1
fi fi
@ -524,14 +524,30 @@ ssl_cert_issue() {
echo -e "${green}Your domain is: ${domain}, checking it...${plain}" echo -e "${green}Your domain is: ${domain}, checking it...${plain}"
SSL_ISSUED_DOMAIN="${domain}" 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 local cert_exists=0
if ~/.acme.sh/acme.sh --list 2> /dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then if ~/.acme.sh/acme.sh --list 2> /dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
cert_exists=1 local acmeCertDir=""
local certInfo=$(~/.acme.sh/acme.sh --list 2> /dev/null | grep -F "${domain}") if [[ -s ~/.acme.sh/${domain}_ecc/fullchain.cer && -s ~/.acme.sh/${domain}_ecc/${domain}.key ]]; then
echo -e "${yellow}Existing certificate found for ${domain}, will reuse it.${plain}" acmeCertDir=~/.acme.sh/${domain}_ecc
[[ -n "${certInfo}" ]] && echo "$certInfo" elif [[ -s ~/.acme.sh/${domain}/fullchain.cer && -s ~/.acme.sh/${domain}/${domain}.key ]]; then
else 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}" echo -e "${green}Your domain is ready for issuing certificates now...${plain}"
fi fi
@ -563,7 +579,7 @@ ssl_cert_issue() {
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force ~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
echo -e "${red}Issuing certificate failed, please check logs.${plain}" 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 systemctl start x-ui 2> /dev/null || rc-service x-ui start 2> /dev/null
return 1 return 1
else else
@ -617,7 +633,7 @@ ssl_cert_issue() {
else else
echo -e "${red}Installing certificate failed, exiting.${plain}" echo -e "${red}Installing certificate failed, exiting.${plain}"
if [[ ${cert_exists} -eq 0 ]]; then if [[ ${cert_exists} -eq 0 ]]; then
rm -rf ~/.acme.sh/${domain} rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
fi fi
systemctl start x-ui 2> /dev/null || rc-service x-ui start 2> /dev/null systemctl start x-ui 2> /dev/null || rc-service x-ui start 2> /dev/null
return 1 return 1

View file

@ -120,16 +120,6 @@ func (s *Server) initRouter() (*gin.Engine, error) {
SubUpdates = "10" 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() SubJsonMux, err := s.settingService.GetSubJsonMux()
if err != nil { if err != nil {
SubJsonMux = "" SubJsonMux = ""
@ -140,6 +130,21 @@ func (s *Server) initRouter() (*gin.Engine, error) {
SubJsonRules = "" 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() SubTitle, err := s.settingService.GetSubTitle()
if err != nil { if err != nil {
SubTitle = "" SubTitle = ""
@ -226,7 +231,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
s.sub = NewSUBController( s.sub = NewSUBController(
g, LinksPath, JsonPath, ClashPath, subJsonEnable, subClashEnable, Encrypt, ShowInfo, RemarkModel, SubUpdates, 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) SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules)
return engine, nil return engine, nil

View file

@ -15,17 +15,13 @@ import (
type SubClashService struct { type SubClashService struct {
inboundService service.InboundService inboundService service.InboundService
enableRouting bool
clashRules string
SubService *SubService SubService *SubService
} }
type ClashConfig struct { func NewSubClashService(enableRouting bool, clashRules string, subService *SubService) *SubClashService {
Proxies []map[string]any `yaml:"proxies"` return &SubClashService{enableRouting: enableRouting, clashRules: clashRules, SubService: subService}
ProxyGroups []map[string]any `yaml:"proxy-groups"`
Rules []string `yaml:"rules"`
}
func NewSubClashService(subService *SubService) *SubClashService {
return &SubClashService{SubService: subService}
} }
func (s *SubClashService) GetClash(subId string, host string) (string, string, error) { 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") proxyNames = append(proxyNames, "DIRECT")
config := ClashConfig{ config := map[string]any{
Proxies: proxies, "proxies": proxies,
ProxyGroups: []map[string]any{{ "proxy-groups": []map[string]any{{
"name": "PROXY", "name": "PROXY",
"type": "select", "type": "select",
"proxies": proxyNames, "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) finalYAML, err := yaml.Marshal(config)
@ -554,3 +556,96 @@ func cloneMap(src map[string]any) map[string]any {
maps.Copy(dst, src) maps.Copy(dst, src)
return dst 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
}

View file

@ -62,10 +62,11 @@ func NewSUBController(
showInfo bool, showInfo bool,
rModel string, rModel string,
update string, update string,
jsonFragment string,
jsonNoise string,
jsonMux string, jsonMux string,
jsonRules string, jsonRules string,
jsonFinalMask string,
clashEnableRouting bool,
clashRules string,
subTitle string, subTitle string,
subSupportUrl string, subSupportUrl string,
subProfileUrl string, subProfileUrl string,
@ -90,8 +91,8 @@ func NewSUBController(
updateInterval: update, updateInterval: update,
subService: sub, subService: sub,
subJsonService: NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub), subJsonService: NewSubJsonService(jsonMux, jsonRules, jsonFinalMask, sub),
subClashService: NewSubClashService(sub), subClashService: NewSubClashService(clashEnableRouting, clashRules, sub),
} }
a.initRouter(g) a.initRouter(g)
return a return a

View file

@ -21,7 +21,7 @@ var defaultJson string
type SubJsonService struct { type SubJsonService struct {
configJson map[string]any configJson map[string]any
defaultOutbounds []json_util.RawMessage defaultOutbounds []json_util.RawMessage
fragmentOrNoises bool finalMask string
mux string mux string
inboundService service.InboundService inboundService service.InboundService
@ -29,7 +29,7 @@ type SubJsonService struct {
} }
// NewSubJsonService creates a new JSON subscription service with the given configuration. // 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 configJson map[string]any
var defaultOutbounds []json_util.RawMessage var defaultOutbounds []json_util.RawMessage
json.Unmarshal([]byte(defaultJson), &configJson) 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 != "" { if rules != "" {
var newRules []any var newRules []any
routing, _ := configJson["routing"].(map[string]any) routing, _ := configJson["routing"].(map[string]any)
@ -78,7 +53,7 @@ func NewSubJsonService(fragment string, noises string, mux string, rules string,
return &SubJsonService{ return &SubJsonService{
configJson: configJson, configJson: configJson,
defaultOutbounds: defaultOutbounds, defaultOutbounds: defaultOutbounds,
fragmentOrNoises: fragmentOrNoises, finalMask: finalMask,
mux: mux, mux: mux,
SubService: subService, SubService: subService,
} }
@ -230,8 +205,8 @@ func (s *SubJsonService) streamData(stream string) map[string]any {
} }
delete(streamSettings, "sockopt") delete(streamSettings, "sockopt")
if s.fragmentOrNoises { if s.finalMask != "" {
streamSettings["sockopt"] = json_util.RawMessage(`{"dialerProxy": "direct_out", "tcpKeepAliveIdle": 100}`) s.applyGlobalFinalMask(streamSettings)
} }
// remove proxy protocol // remove proxy protocol
@ -255,6 +230,17 @@ func (s *SubJsonService) streamData(stream string) map[string]any {
return streamSettings 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 { func (s *SubJsonService) removeAcceptProxy(setting any) map[string]any {
netSettings, ok := setting.(map[string]any) netSettings, ok := setting.(map[string]any)
if ok { 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 { func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client) json_util.RawMessage {
outbound := Outbound{} 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.Protocol = string(inbound.Protocol)
outbound.Tag = "proxy" 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.Mux = json_util.RawMessage(s.mux)
} }
outbound.StreamSettings = streamSettings outbound.StreamSettings = streamSettings
security := client.Security
if security == "" {
security = "auto"
}
outbound.Settings = map[string]any{ outbound.Settings = map[string]any{
"vnext": vnextData, "address": inbound.Listen,
"port": inbound.Port,
"id": client.ID,
"security": security,
"level": 8,
} }
result, _ := json.MarshalIndent(outbound, "", " ") result, _ := json.MarshalIndent(outbound, "", " ")
@ -347,24 +331,17 @@ func (s *SubJsonService) genVless(inbound *model.Inbound, streamSettings json_ut
json.Unmarshal([]byte(inbound.Settings), &inboundSettings) json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
encryption, _ := inboundSettings["encryption"].(string) encryption, _ := inboundSettings["encryption"].(string)
user := map[string]any{ settings := map[string]any{
"address": inbound.Listen,
"port": inbound.Port,
"id": client.ID, "id": client.ID,
"level": 8,
"encryption": encryption, "encryption": encryption,
"level": 8,
} }
if client.Flow != "" { if client.Flow != "" {
user["flow"] = client.Flow settings["flow"] = client.Flow
}
vnext := map[string]any{
"address": inbound.Listen,
"port": inbound.Port,
"users": []any{user},
}
outbound.Settings = map[string]any{
"vnext": []any{vnext},
} }
outbound.Settings = settings
result, _ := json.MarshalIndent(outbound, "", " ") result, _ := json.MarshalIndent(outbound, "", " ")
return result return result
} }
@ -400,9 +377,17 @@ func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_u
outbound.Mux = json_util.RawMessage(s.mux) outbound.Mux = json_util.RawMessage(s.mux)
} }
outbound.StreamSettings = streamSettings 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, "", " ") result, _ := json.MarshalIndent(outbound, "", " ")
return result return result
@ -442,7 +427,7 @@ func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any,
newStream["hysteriaSettings"] = outHyStream newStream["hysteriaSettings"] = outHyStream
if finalmask, ok := hyStream["finalmask"].(map[string]any); ok { if finalmask, ok := hyStream["finalmask"].(map[string]any); ok {
newStream["finalmask"] = finalmask newStream["finalmask"] = mergeFinalMask(newStream["finalmask"], finalmask)
} }
newStream["network"] = "hysteria" newStream["network"] = "hysteria"
@ -454,6 +439,41 @@ func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any,
return result 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 { type Outbound struct {
Protocol string `json:"protocol"` Protocol string `json:"protocol"`
Tag string `json:"tag"` Tag string `json:"tag"`
@ -462,18 +482,6 @@ type Outbound struct {
Settings map[string]any `json:"settings,omitempty"` 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 { type ServerSetting struct {
Password string `json:"password"` Password string `json:"password"`
Level int `json:"level"` Level int `json:"level"`

148
sub/subJsonService_test.go Normal file
View 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)
}
}

View file

@ -3,6 +3,8 @@ package controller
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"strconv"
"strings"
"time" "time"
"github.com/mhsanaei/3x-ui/v3/database/model" "github.com/mhsanaei/3x-ui/v3/database/model"
@ -16,6 +18,21 @@ func notifyClientsChanged() {
websocket.BroadcastInvalidate(websocket.MessageTypeClients) 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 { type ClientController struct {
clientService service.ClientService clientService service.ClientService
inboundService service.InboundService inboundService service.InboundService
@ -129,7 +146,8 @@ func (a *ClientController) update(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return 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 { if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return return

View file

@ -83,10 +83,11 @@ type AllSetting struct {
SubClashEnable bool `json:"subClashEnable" form:"subClashEnable"` // Enable Clash/Mihomo subscription endpoint SubClashEnable bool `json:"subClashEnable" form:"subClashEnable"` // Enable Clash/Mihomo subscription endpoint
SubClashPath string `json:"subClashPath" form:"subClashPath"` // Path for 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 SubClashURI string `json:"subClashURI" form:"subClashURI"` // Clash/Mihomo subscription server URI
SubJsonFragment string `json:"subJsonFragment" form:"subJsonFragment"` // JSON subscription fragment configuration SubClashEnableRouting bool `json:"subClashEnableRouting" form:"subClashEnableRouting"` // Enable global routing rules for Clash/Mihomo
SubJsonNoises string `json:"subJsonNoises" form:"subJsonNoises"` // JSON subscription noise configuration SubClashRules string `json:"subClashRules" form:"subClashRules"` // Clash/Mihomo global routing rules
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration
SubJsonRules string `json:"subJsonRules" form:"subJsonRules"` SubJsonRules string `json:"subJsonRules" form:"subJsonRules"`
SubJsonFinalMask string `json:"subJsonFinalMask" form:"subJsonFinalMask"` // JSON subscription global finalmask (tcp/udp masks + quicParams)
// LDAP settings // LDAP settings
LdapEnable bool `json:"ldapEnable" form:"ldapEnable"` LdapEnable bool `json:"ldapEnable" form:"ldapEnable"`

View file

@ -286,15 +286,17 @@ func (r *Remote) AddClient(ctx context.Context, ib *model.Inbound, client model.
return nil return nil
} }
// DeleteUser is idempotent: master's per-inbound Delete loop may call it func (r *Remote) DeleteUser(ctx context.Context, ib *model.Inbound, email string) error {
// 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 {
if email == "" { if email == "" {
return nil return nil
} }
_, err := r.do(ctx, http.MethodPost, id, err := r.resolveRemoteID(ctx, ib.Tag)
"panel/api/clients/del/"+url.PathEscape(email), nil) 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 { if err == nil {
return nil return nil
} }
@ -304,12 +306,17 @@ func (r *Remote) DeleteUser(ctx context.Context, _ *model.Inbound, email string)
return err 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 == "" { if oldEmail == "" {
oldEmail = payload.Email oldEmail = payload.Email
} }
if _, err := r.do(ctx, http.MethodPost, id, err := r.resolveRemoteID(ctx, ib.Tag)
"panel/api/clients/update/"+url.PathEscape(oldEmail), payload); err != nil { 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 err
} }
return nil return nil

View 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)))
})
}
}

View 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)
}
}

File diff suppressed because it is too large Load diff

View file

@ -83,8 +83,17 @@ func (s *InboundService) enrichClientStats(db *gorm.DB, inbounds []*model.Inboun
emails = append(emails, e) emails = append(emails, e)
} }
var extra []xray.ClientTraffic var extra []xray.ClientTraffic
if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", emails).Find(&extra).Error; err != nil { var loadErr error
logger.Warning("enrichClientStats:", err) 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 { } else {
byEmail := make(map[string]xray.ClientTraffic, len(extra)) byEmail := make(map[string]xray.ClientTraffic, len(extra))
for _, st := range extra { for _, st := range extra {
@ -438,6 +447,37 @@ func (s *InboundService) emailUsedByOtherInbounds(email string, exceptInboundId
return count > 0, nil 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. // normalizeStreamSettings clears StreamSettings for protocols that don't use it.
// Only vmess, vless, trojan, shadowsocks, and hysteria protocols use streamSettings. // Only vmess, vless, trojan, shadowsocks, and hysteria protocols use streamSettings.
func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) { func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) {
@ -1575,12 +1615,19 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
structuralChange = true 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( if err := tx.Exec(
fmt.Sprintf( fmt.Sprintf(
`UPDATE client_traffics `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 last_online = %s
WHERE email = ?`, WHERE email = ?`,
enableExpr,
database.GreatestExpr("last_online", "?"), database.GreatestExpr("last_online", "?"),
), ),
deltaUp, deltaDown, cs.Enable, cs.Total, cs.ExpiryTime, cs.Reset, 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 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) { func (s *InboundService) GetClientInboundByTrafficID(trafficId int) (traffic *xray.ClientTraffic, inbound *model.Inbound, err error) {
db := database.GetDB() db := database.GetDB()
var traffics []*xray.ClientTraffic 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) { func (s *InboundService) GetClientTrafficByEmail(email string) (traffic *xray.ClientTraffic, err error) {
// Prefer retrieving along with client to reflect actual enabled state from inbound settings db := database.GetDB()
t, client, err := s.GetClientByEmail(email) 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 { if err != nil {
logger.Warningf("Error retrieving ClientTraffic with email %s: %v", email, err) logger.Warningf("Error retrieving ClientTraffic with email %s: %v", email, err)
return nil, err return nil, err
} }
if t != nil && client != nil { if t2 != nil && client != nil {
t.UUID = client.ID t2.UUID = client.ID
t.SubId = client.SubID t2.SubId = client.SubID
return t, nil return t2, nil
} }
return nil, nil return nil, nil
} }
@ -3357,6 +3447,9 @@ func (s *InboundService) MigrateDB() {
} }
func (s *InboundService) GetOnlineClients() []string { func (s *InboundService) GetOnlineClients() []string {
if p == nil {
return []string{}
}
return p.GetOnlineClients() return p.GetOnlineClients()
} }

View file

@ -79,10 +79,11 @@ var defaultValueMap = map[string]string{
"subClashEnable": "false", "subClashEnable": "false",
"subClashPath": "/clash/", "subClashPath": "/clash/",
"subClashURI": "", "subClashURI": "",
"subJsonFragment": "", "subClashEnableRouting": "false",
"subJsonNoises": "", "subClashRules": "",
"subJsonMux": "", "subJsonMux": "",
"subJsonRules": "", "subJsonRules": "",
"subJsonFinalMask": "",
"datepicker": "gregorian", "datepicker": "gregorian",
"warp": "", "warp": "",
"nord": "", "nord": "",
@ -658,12 +659,12 @@ func (s *SettingService) GetSubClashURI() (string, error) {
return s.getString("subClashURI") return s.getString("subClashURI")
} }
func (s *SettingService) GetSubJsonFragment() (string, error) { func (s *SettingService) GetSubClashEnableRouting() (bool, error) {
return s.getString("subJsonFragment") return s.getBool("subClashEnableRouting")
} }
func (s *SettingService) GetSubJsonNoises() (string, error) { func (s *SettingService) GetSubClashRules() (string, error) {
return s.getString("subJsonNoises") return s.getString("subClashRules")
} }
func (s *SettingService) GetSubJsonMux() (string, error) { func (s *SettingService) GetSubJsonMux() (string, error) {
@ -674,6 +675,10 @@ func (s *SettingService) GetSubJsonRules() (string, error) {
return s.getString("subJsonRules") return s.getString("subJsonRules")
} }
func (s *SettingService) GetSubJsonFinalMask() (string, error) {
return s.getString("subJsonFinalMask")
}
func (s *SettingService) GetDatepicker() (string, error) { func (s *SettingService) GetDatepicker() (string, error) {
return s.getString("datepicker") return s.getString("datepicker")
} }

View 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))
})
}
}

View file

@ -495,6 +495,10 @@ func (t *Tgbot) OnReceive() {
}, th.TextEqual(t.I18nBot("tgbot.buttons.closeKeyboard"))) }, th.TextEqual(t.I18nBot("tgbot.buttons.closeKeyboard")))
h.HandleMessage(func(ctx *th.Context, message telego.Message) error { 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 // Use goroutine with worker pool for concurrent command processing
go func() { go func() {
messageWorkerPool <- struct{}{} // Acquire worker 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. // sendResponse sends the response message based on the onlyMessage flag.
func (t *Tgbot) sendResponse(chatId int64, msg string, onlyMessage, isAdmin bool) { func (t *Tgbot) sendResponse(chatId int64, msg string, onlyMessage, isAdmin bool) {
if onlyMessage { if onlyMessage {

View file

@ -99,3 +99,27 @@ func TestTgbotProxyDialerNoneWhenEmpty(t *testing.T) {
t.Fatal("Dial must be nil when no proxy is configured") 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")
}
}

View file

@ -1004,6 +1004,10 @@
"subEnableRoutingDesc": "إعداد عام لتمكين التوجيه (Routing) في عميل VPN. (فقط لـ Happ)", "subEnableRoutingDesc": "إعداد عام لتمكين التوجيه (Routing) في عميل VPN. (فقط لـ Happ)",
"subRoutingRules": "قواعد التوجيه", "subRoutingRules": "قواعد التوجيه",
"subRoutingRulesDesc": "قواعد التوجيه العامة لعميل VPN. (فقط لـ Happ)", "subRoutingRulesDesc": "قواعد التوجيه العامة لعميل VPN. (فقط لـ Happ)",
"subClashEnableRouting": "تفعيل التوجيه",
"subClashEnableRoutingDesc": "تضمين قواعد توجيه Clash/Mihomo العامة في اشتراكات YAML المُنشأة.",
"subClashRoutingRules": "قواعد التوجيه العامة",
"subClashRoutingRulesDesc": "قواعد Clash/Mihomo التي تُضاف في بداية كل اشتراك YAML قبل MATCH,PROXY.",
"subListen": "IP الاستماع", "subListen": "IP الاستماع",
"subListenDesc": "عنوان IP لخدمة الاشتراك. (سيبه فاضي عشان يستمع على كل الـ IPs)", "subListenDesc": "عنوان IP لخدمة الاشتراك. (سيبه فاضي عشان يستمع على كل الـ IPs)",
"subPort": "بورت الاستماع", "subPort": "بورت الاستماع",
@ -1070,6 +1074,8 @@
"defaultIpLimit": "حد IP الافتراضي" "defaultIpLimit": "حد IP الافتراضي"
}, },
"subFormats": { "subFormats": {
"finalMask": "Final Mask",
"finalMaskDesc": "أقنعة finalmask في xray (TCP/UDP) وضبط QUIC تُضاف إلى كل تدفق اشتراك JSON. يتطلب إصدار xray حديثًا على العميل.",
"packets": "الحزم", "packets": "الحزم",
"length": "الطول", "length": "الطول",
"interval": "الفاصل", "interval": "الفاصل",

View file

@ -1004,6 +1004,10 @@
"subEnableRoutingDesc": "Global setting to enable routing in the VPN client. (Only for Happ)", "subEnableRoutingDesc": "Global setting to enable routing in the VPN client. (Only for Happ)",
"subRoutingRules": "Routing rules", "subRoutingRules": "Routing rules",
"subRoutingRulesDesc": "Global routing rules for the VPN client. (Only for Happ)", "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", "subListen": "Listen IP",
"subListenDesc": "The IP address for the subscription service. (leave blank to listen on all IPs)", "subListenDesc": "The IP address for the subscription service. (leave blank to listen on all IPs)",
"subPort": "Listen Port", "subPort": "Listen Port",
@ -1070,6 +1074,8 @@
"defaultIpLimit": "Default IP limit" "defaultIpLimit": "Default IP limit"
}, },
"subFormats": { "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", "packets": "Packets",
"length": "Length", "length": "Length",
"interval": "Interval", "interval": "Interval",

View file

@ -1004,6 +1004,10 @@
"subEnableRoutingDesc": "Configuración global para habilitar el enrutamiento en el cliente VPN. (Solo para Happ)", "subEnableRoutingDesc": "Configuración global para habilitar el enrutamiento en el cliente VPN. (Solo para Happ)",
"subRoutingRules": "Reglas de enrutamiento", "subRoutingRules": "Reglas de enrutamiento",
"subRoutingRulesDesc": "Reglas de enrutamiento globales para el cliente VPN. (Solo para Happ)", "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", "subListen": "Listening IP",
"subListenDesc": "Dejar en blanco por defecto para monitorear todas las IPs.", "subListenDesc": "Dejar en blanco por defecto para monitorear todas las IPs.",
"subPort": "Puerto de Suscripción", "subPort": "Puerto de Suscripción",
@ -1070,6 +1074,8 @@
"defaultIpLimit": "Límite IP por defecto" "defaultIpLimit": "Límite IP por defecto"
}, },
"subFormats": { "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", "packets": "Paquetes",
"length": "Longitud", "length": "Longitud",
"interval": "Intervalo", "interval": "Intervalo",

View file

@ -1004,6 +1004,10 @@
"subEnableRoutingDesc": "تنظیمات سراسری برای فعال‌سازی مسیریابی در کلاینت VPN. (فقط برای Happ)", "subEnableRoutingDesc": "تنظیمات سراسری برای فعال‌سازی مسیریابی در کلاینت VPN. (فقط برای Happ)",
"subRoutingRules": "قوانین مسیریابی", "subRoutingRules": "قوانین مسیریابی",
"subRoutingRulesDesc": "قوانین مسیریابی سراسری برای کلاینت VPN. (فقط برای Happ)", "subRoutingRulesDesc": "قوانین مسیریابی سراسری برای کلاینت VPN. (فقط برای Happ)",
"subClashEnableRouting": "فعال‌سازی مسیریابی",
"subClashEnableRoutingDesc": "قوانین مسیریابی سراسری Clash/Mihomo را در اشتراک‌های YAML تولیدشده وارد کن.",
"subClashRoutingRules": "قوانین مسیریابی سراسری",
"subClashRoutingRulesDesc": "قوانین Clash/Mihomo که پیش از MATCH,PROXY به ابتدای هر اشتراک YAML افزوده می‌شوند.",
"subListen": "آدرس آی‌پی", "subListen": "آدرس آی‌پی",
"subListenDesc": "آدرس آی‌پی برای سرویس سابسکریپشن. برای گوش دادن به‌تمام آی‌پی‌ها خالی‌بگذارید", "subListenDesc": "آدرس آی‌پی برای سرویس سابسکریپشن. برای گوش دادن به‌تمام آی‌پی‌ها خالی‌بگذارید",
"subPort": "پورت", "subPort": "پورت",
@ -1070,6 +1074,8 @@
"defaultIpLimit": "محدودیت IP پیش‌فرض" "defaultIpLimit": "محدودیت IP پیش‌فرض"
}, },
"subFormats": { "subFormats": {
"finalMask": "Final Mask",
"finalMaskDesc": "ماسک‌های finalmask ایکس‌ری (TCP/UDP) و تنظیمات QUIC که داخل همه‌ی stream های اشتراک JSON تزریق می‌شوند. به نسخه‌ی جدید هسته‌ی xray در کلاینت نیاز دارد.",
"packets": "بسته‌ها", "packets": "بسته‌ها",
"length": "طول", "length": "طول",
"interval": "بازه", "interval": "بازه",

View file

@ -1004,6 +1004,10 @@
"subEnableRoutingDesc": "Pengaturan global untuk mengaktifkan perutean (routing) di klien VPN. (Hanya untuk Happ)", "subEnableRoutingDesc": "Pengaturan global untuk mengaktifkan perutean (routing) di klien VPN. (Hanya untuk Happ)",
"subRoutingRules": "Aturan routing", "subRoutingRules": "Aturan routing",
"subRoutingRulesDesc": "Aturan routing global untuk klien VPN. (Hanya untuk Happ)", "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", "subListen": "IP Pendengar",
"subListenDesc": "Alamat IP untuk layanan langganan. (biarkan kosong untuk mendengarkan semua IP)", "subListenDesc": "Alamat IP untuk layanan langganan. (biarkan kosong untuk mendengarkan semua IP)",
"subPort": "Port Pendengar", "subPort": "Port Pendengar",
@ -1070,6 +1074,8 @@
"defaultIpLimit": "Batas IP default" "defaultIpLimit": "Batas IP default"
}, },
"subFormats": { "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", "packets": "Paket",
"length": "Panjang", "length": "Panjang",
"interval": "Interval", "interval": "Interval",

View file

@ -1004,6 +1004,10 @@
"subEnableRoutingDesc": "VPNクライアントでルーティングを有効にするためのグローバル設定。(Happのみ)", "subEnableRoutingDesc": "VPNクライアントでルーティングを有効にするためのグローバル設定。(Happのみ)",
"subRoutingRules": "ルーティングルール", "subRoutingRules": "ルーティングルール",
"subRoutingRulesDesc": "VPNクライアントのグローバルルーティングルール。(Happのみ)", "subRoutingRulesDesc": "VPNクライアントのグローバルルーティングルール。(Happのみ)",
"subClashEnableRouting": "ルーティングを有効化",
"subClashEnableRoutingDesc": "生成されたYAMLサブスクリプションにClash/Mihomoのグローバルルーティングルールを含めます。",
"subClashRoutingRules": "グローバルルーティングルール",
"subClashRoutingRulesDesc": "各YAMLサブスクリプションのMATCH,PROXYより前に追加されるClash/Mihomoルール。",
"subListen": "監視IP", "subListen": "監視IP",
"subListenDesc": "サブスクリプションサービスが監視するIPアドレス空白にするとすべてのIPを監視", "subListenDesc": "サブスクリプションサービスが監視するIPアドレス空白にするとすべてのIPを監視",
"subPort": "監視ポート", "subPort": "監視ポート",
@ -1070,6 +1074,8 @@
"defaultIpLimit": "デフォルト IP 制限" "defaultIpLimit": "デフォルト IP 制限"
}, },
"subFormats": { "subFormats": {
"finalMask": "Final Mask",
"finalMaskDesc": "すべての JSON サブスクリプションストリームに注入される xray finalmask マスクTCP/UDPと QUIC チューニング。新しい xray クライアントが必要です。",
"packets": "パケット", "packets": "パケット",
"length": "長さ", "length": "長さ",
"interval": "間隔", "interval": "間隔",

View file

@ -1004,6 +1004,10 @@
"subEnableRoutingDesc": "Configuração global para habilitar o roteamento no cliente VPN. (Apenas para Happ)", "subEnableRoutingDesc": "Configuração global para habilitar o roteamento no cliente VPN. (Apenas para Happ)",
"subRoutingRules": "Regras de roteamento", "subRoutingRules": "Regras de roteamento",
"subRoutingRulesDesc": "Regras de roteamento globais para o cliente VPN. (Apenas para Happ)", "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", "subListen": "IP de Escuta",
"subListenDesc": "O endereço IP para o serviço de assinatura. (deixe em branco para escutar em todos os IPs)", "subListenDesc": "O endereço IP para o serviço de assinatura. (deixe em branco para escutar em todos os IPs)",
"subPort": "Porta de Escuta", "subPort": "Porta de Escuta",
@ -1070,6 +1074,8 @@
"defaultIpLimit": "Limite de IP padrão" "defaultIpLimit": "Limite de IP padrão"
}, },
"subFormats": { "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", "packets": "Pacotes",
"length": "Comprimento", "length": "Comprimento",
"interval": "Intervalo", "interval": "Intervalo",

View file

@ -1004,6 +1004,10 @@
"subEnableRoutingDesc": "Глобальная настройка для включения маршрутизации в VPN-клиенте. (Только для Happ)", "subEnableRoutingDesc": "Глобальная настройка для включения маршрутизации в VPN-клиенте. (Только для Happ)",
"subRoutingRules": "Правила маршрутизации", "subRoutingRules": "Правила маршрутизации",
"subRoutingRulesDesc": "Глобальные правила маршрутизации для VPN-клиента. (Только для Happ)", "subRoutingRulesDesc": "Глобальные правила маршрутизации для VPN-клиента. (Только для Happ)",
"subClashEnableRouting": "Включить маршрутизацию",
"subClashEnableRoutingDesc": "Добавлять глобальные правила маршрутизации Clash/Mihomo в сгенерированные YAML-подписки.",
"subClashRoutingRules": "Глобальные правила маршрутизации",
"subClashRoutingRulesDesc": "Правила Clash/Mihomo, добавляемые в начало каждой YAML-подписки перед MATCH,PROXY.",
"subListen": "Прослушивание IP", "subListen": "Прослушивание IP",
"subListenDesc": "Оставьте пустым по умолчанию, чтобы отслеживать все IP-адреса", "subListenDesc": "Оставьте пустым по умолчанию, чтобы отслеживать все IP-адреса",
"subPort": "Порт подписки", "subPort": "Порт подписки",
@ -1070,6 +1074,8 @@
"defaultIpLimit": "Лимит IP по умолчанию" "defaultIpLimit": "Лимит IP по умолчанию"
}, },
"subFormats": { "subFormats": {
"finalMask": "Final Mask",
"finalMaskDesc": "Маски finalmask xray (TCP/UDP) и настройки QUIC, добавляемые в каждый поток JSON-подписки. Требуется свежая версия xray на клиенте.",
"packets": "Пакеты", "packets": "Пакеты",
"length": "Длина", "length": "Длина",
"interval": "Интервал", "interval": "Интервал",

View file

@ -1004,6 +1004,10 @@
"subEnableRoutingDesc": "VPN istemcisinde yönlendirmeyi etkinleştirmek için genel ayar. (Yalnızca Happ için)", "subEnableRoutingDesc": "VPN istemcisinde yönlendirmeyi etkinleştirmek için genel ayar. (Yalnızca Happ için)",
"subRoutingRules": "Yönlendirme kuralları", "subRoutingRules": "Yönlendirme kuralları",
"subRoutingRulesDesc": "VPN istemcisi için genel yönlendirme kuralları. (Yalnızca Happ için)", "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", "subListen": "Dinleme IP",
"subListenDesc": "Abonelik hizmeti için IP adresi. (tüm IP'leri dinlemek için boş bırakın)", "subListenDesc": "Abonelik hizmeti için IP adresi. (tüm IP'leri dinlemek için boş bırakın)",
"subPort": "Dinleme Portu", "subPort": "Dinleme Portu",
@ -1070,6 +1074,8 @@
"defaultIpLimit": "Varsayılan IP limiti" "defaultIpLimit": "Varsayılan IP limiti"
}, },
"subFormats": { "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", "packets": "Paketler",
"length": "Uzunluk", "length": "Uzunluk",
"interval": "Aralık", "interval": "Aralık",

View file

@ -1004,6 +1004,10 @@
"subEnableRoutingDesc": "Глобальне налаштування для увімкнення маршрутизації у VPN-клієнті. (Тільки для Happ)", "subEnableRoutingDesc": "Глобальне налаштування для увімкнення маршрутизації у VPN-клієнті. (Тільки для Happ)",
"subRoutingRules": "Правила маршрутизації", "subRoutingRules": "Правила маршрутизації",
"subRoutingRulesDesc": "Глобальні правила маршрутизації для VPN-клієнта. (Тільки для Happ)", "subRoutingRulesDesc": "Глобальні правила маршрутизації для VPN-клієнта. (Тільки для Happ)",
"subClashEnableRouting": "Увімкнути маршрутизацію",
"subClashEnableRoutingDesc": "Додавати глобальні правила маршрутизації Clash/Mihomo до згенерованих YAML-підписок.",
"subClashRoutingRules": "Глобальні правила маршрутизації",
"subClashRoutingRulesDesc": "Правила Clash/Mihomo, що додаються на початок кожної YAML-підписки перед MATCH,PROXY.",
"subListen": "Слухати IP", "subListen": "Слухати IP",
"subListenDesc": "IP-адреса для служби підписки. (залиште порожнім, щоб слухати всі IP-адреси)", "subListenDesc": "IP-адреса для служби підписки. (залиште порожнім, щоб слухати всі IP-адреси)",
"subPort": "Слухати порт", "subPort": "Слухати порт",
@ -1070,6 +1074,8 @@
"defaultIpLimit": "Ліміт IP за замовч." "defaultIpLimit": "Ліміт IP за замовч."
}, },
"subFormats": { "subFormats": {
"finalMask": "Final Mask",
"finalMaskDesc": "Маски finalmask xray (TCP/UDP) і налаштування QUIC, що додаються до кожного потоку JSON-підписки. Потрібна свіжа версія xray на клієнті.",
"packets": "Пакети", "packets": "Пакети",
"length": "Довжина", "length": "Довжина",
"interval": "Інтервал", "interval": "Інтервал",

View file

@ -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)", "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", "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)", "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", "subListen": "Listening IP",
"subListenDesc": "Mặc định để trống để nghe tất cả các IP", "subListenDesc": "Mặc định để trống để nghe tất cả các IP",
"subPort": "Cổng gói đăng ký", "subPort": "Cổng gói đăng ký",
@ -1070,6 +1074,8 @@
"defaultIpLimit": "Giới hạn IP mặc định" "defaultIpLimit": "Giới hạn IP mặc định"
}, },
"subFormats": { "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", "packets": "Gói",
"length": "Độ dài", "length": "Độ dài",
"interval": "Khoảng", "interval": "Khoảng",

View file

@ -1004,6 +1004,10 @@
"subEnableRoutingDesc": "在 VPN 客户端中启用路由的全局设置。(僅限 Happ", "subEnableRoutingDesc": "在 VPN 客户端中启用路由的全局设置。(僅限 Happ",
"subRoutingRules": "路由規則", "subRoutingRules": "路由規則",
"subRoutingRulesDesc": "VPN 用戶端的全域路由規則。(僅限 Happ", "subRoutingRulesDesc": "VPN 用戶端的全域路由規則。(僅限 Happ",
"subClashEnableRouting": "启用路由",
"subClashEnableRoutingDesc": "在生成的 YAML 订阅中包含 Clash/Mihomo 全局路由规则。",
"subClashRoutingRules": "全局路由规则",
"subClashRoutingRulesDesc": "添加到每个 YAML 订阅开头、MATCH,PROXY 之前的 Clash/Mihomo 规则。",
"subListen": "监听 IP", "subListen": "监听 IP",
"subListenDesc": "订阅服务监听的 IP 地址(留空表示监听所有 IP", "subListenDesc": "订阅服务监听的 IP 地址(留空表示监听所有 IP",
"subPort": "监听端口", "subPort": "监听端口",
@ -1070,6 +1074,8 @@
"defaultIpLimit": "默认 IP 限制" "defaultIpLimit": "默认 IP 限制"
}, },
"subFormats": { "subFormats": {
"finalMask": "Final Mask",
"finalMaskDesc": "注入到每个 JSON 订阅流的 xray finalmask 掩码TCP/UDP和 QUIC 调优。需要较新的 xray 客户端。",
"packets": "数据包", "packets": "数据包",
"length": "长度", "length": "长度",
"interval": "间隔", "interval": "间隔",

View file

@ -1004,6 +1004,10 @@
"subEnableRoutingDesc": "在 VPN 用戶端中啟用路由的全域設定。(僅限 Happ", "subEnableRoutingDesc": "在 VPN 用戶端中啟用路由的全域設定。(僅限 Happ",
"subRoutingRules": "路由規則", "subRoutingRules": "路由規則",
"subRoutingRulesDesc": "VPN 用戶端的全域路由規則。(僅限 Happ", "subRoutingRulesDesc": "VPN 用戶端的全域路由規則。(僅限 Happ",
"subClashEnableRouting": "啟用路由",
"subClashEnableRoutingDesc": "在產生的 YAML 訂閱中包含 Clash/Mihomo 全域路由規則。",
"subClashRoutingRules": "全域路由規則",
"subClashRoutingRulesDesc": "加入到每個 YAML 訂閱開頭、MATCH,PROXY 之前的 Clash/Mihomo 規則。",
"subListen": "監聽 IP", "subListen": "監聽 IP",
"subListenDesc": "訂閱服務監聽的 IP 地址(留空表示監聽所有 IP", "subListenDesc": "訂閱服務監聽的 IP 地址(留空表示監聽所有 IP",
"subPort": "監聽埠", "subPort": "監聽埠",
@ -1070,6 +1074,8 @@
"defaultIpLimit": "預設 IP 限制" "defaultIpLimit": "預設 IP 限制"
}, },
"subFormats": { "subFormats": {
"finalMask": "Final Mask",
"finalMaskDesc": "注入到每個 JSON 訂閱串流的 xray finalmask 遮罩TCP/UDP與 QUIC 調校。需要較新的 xray 用戶端。",
"packets": "封包", "packets": "封包",
"length": "長度", "length": "長度",
"interval": "間隔", "interval": "間隔",

67
x-ui.sh
View file

@ -1269,6 +1269,16 @@ ssl_cert_issue_main() {
echo "Panel paths set for domain: $domain" echo "Panel paths set for domain: $domain"
echo " - Certificate File: $webCertFile" echo " - Certificate File: $webCertFile"
echo " - Private Key File: $webKeyFile" 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 restart
else else
echo "Certificate or private key not found for domain: $domain." 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 "Failed to issue certificate for IP: ${server_ip}"
LOGE "Make sure port ${WebPort} is open and the server is accessible from the internet" 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 # Cleanup acme.sh data for both IPv4 and IPv6 if specified
rm -rf ~/.acme.sh/${server_ip} 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} 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 rm -rf ${certPath} 2> /dev/null
return 1 return 1
else else
@ -1468,8 +1478,8 @@ ssl_cert_issue_for_ip() {
if [[ ! -f "${certPath}/fullchain.pem" || ! -f "${certPath}/privkey.pem" ]]; then if [[ ! -f "${certPath}/fullchain.pem" || ! -f "${certPath}/privkey.pem" ]]; then
LOGE "Certificate files not found after installation" LOGE "Certificate files not found after installation"
# Cleanup acme.sh data for both IPv4 and IPv6 if specified # Cleanup acme.sh data for both IPv4 and IPv6 if specified
rm -rf ~/.acme.sh/${server_ip} 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} 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 rm -rf ${certPath} 2> /dev/null
return 1 return 1
fi fi
@ -1576,14 +1586,30 @@ ssl_cert_issue() {
LOGD "Your domain is: ${domain}, checking it..." LOGD "Your domain is: ${domain}, checking it..."
SSL_ISSUED_DOMAIN="${domain}" 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 local cert_exists=0
if ~/.acme.sh/acme.sh --list 2> /dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then if ~/.acme.sh/acme.sh --list 2> /dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
cert_exists=1 local acmeCertDir=""
local certInfo=$(~/.acme.sh/acme.sh --list 2> /dev/null | grep -F "${domain}") if [[ -s ~/.acme.sh/${domain}_ecc/fullchain.cer && -s ~/.acme.sh/${domain}_ecc/${domain}.key ]]; then
LOGI "Existing certificate found for ${domain}, will reuse it." acmeCertDir=~/.acme.sh/${domain}_ecc
[[ -n "${certInfo}" ]] && LOGI "${certInfo}" elif [[ -s ~/.acme.sh/${domain}/fullchain.cer && -s ~/.acme.sh/${domain}/${domain}.key ]]; then
else 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..." LOGI "Your domain is ready for issuing certificates now..."
fi fi
@ -1611,7 +1637,7 @@ ssl_cert_issue() {
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force ~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport ${WebPort} --force
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
LOGE "Issuing certificate failed, please check logs." LOGE "Issuing certificate failed, please check logs."
rm -rf ~/.acme.sh/${domain} rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
exit 1 exit 1
else else
LOGE "Issuing certificate succeeded, installing certificates..." LOGE "Issuing certificate succeeded, installing certificates..."
@ -1664,7 +1690,7 @@ ssl_cert_issue() {
else else
LOGE "Installing certificate failed, exiting." LOGE "Installing certificate failed, exiting."
if [[ ${cert_exists} -eq 0 ]]; then if [[ ${cert_exists} -eq 0 ]]; then
rm -rf ~/.acme.sh/${domain} rm -rf ~/.acme.sh/${domain} ~/.acme.sh/${domain}_ecc
fi fi
exit 1 exit 1
fi fi
@ -2248,6 +2274,18 @@ failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnect
ignoreregex = ignoreregex =
EOF 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 cat << EOF > /etc/fail2ban/action.d/3x-ipl.conf
[INCLUDES] [INCLUDES]
before = iptables-allports.conf 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]' 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} 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} echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> ${iplimit_banned_log_path}
[Init] [Init]
name = default name = default
protocol = tcp protocol = tcp
chain = INPUT chain = INPUT
exemptports = ${exempt_ports}
EOF EOF
echo -e "${green}Ip Limit jail files created with a bantime of ${bantime} minutes.${plain}" echo -e "${green}Ip Limit jail files created with a bantime of ${bantime} minutes.${plain}"