diff --git a/DockerEntrypoint.sh b/DockerEntrypoint.sh index 38786b14..9105f965 100644 --- a/DockerEntrypoint.sh +++ b/DockerEntrypoint.sh @@ -27,6 +27,16 @@ failregex = \[LIMIT_IP\]\s*Email\s*=\s*.+\s*\|\|\s*Disconnect ignoreregex = EOF + # Ports to exempt from the ban so an over-limit proxy client can never lock + # the administrator out of SSH or the panel. The ban still covers every other + # TCP port (including all Xray inbounds), so IP-limit keeps working for inbounds + # added later without regenerating these files. + SSH_PORTS=$(grep -oE '^[[:space:]]*Port[[:space:]]+[0-9]+' /etc/ssh/sshd_config 2>/dev/null | grep -oE '[0-9]+' | paste -sd, -) + [ -z "$SSH_PORTS" ] && SSH_PORTS="22" + PANEL_PORT=$(/app/x-ui setting -show true 2>/dev/null | grep -Eo 'port: .+' | awk '{print $2}') + EXEMPT_PORTS="$SSH_PORTS" + [ -n "$PANEL_PORT" ] && EXEMPT_PORTS="$EXEMPT_PORTS,$PANEL_PORT" + cat > /etc/fail2ban/action.d/3x-ipl.conf << EOF [INCLUDES] before = iptables-allports.conf @@ -42,16 +52,17 @@ actionstop = -D -p -j f2b- actioncheck = -n -L | grep -q 'f2b-[ \t]' -actionban = -I f2b- 1 -s -j +actionban = -I f2b- 1 -s -p -m multiport ! --dports -j echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") BAN [Email] = [IP] = banned for seconds." >> $LOG_FOLDER/3xipl-banned.log -actionunban = -D f2b- -s -j +actionunban = -D f2b- -s -p -m multiport ! --dports -j echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") UNBAN [Email] = [IP] = unbanned." >> $LOG_FOLDER/3xipl-banned.log [Init] name = default protocol = tcp chain = INPUT +exemptports = $EXEMPT_PORTS EOF fail2ban-client -x start diff --git a/database/migrate_data.go b/database/migrate_data.go index d4e6cdec..89c8387c 100644 --- a/database/migrate_data.go +++ b/database/migrate_data.go @@ -86,6 +86,14 @@ func MigrateData(srcPath, dstDSN string) error { } } + // AutoMigrate re-creates the legacy client_traffics -> inbounds foreign key, + // but the running panel drops it (see dropLegacyForeignKeys) and tolerates + // client_traffics rows whose inbound was deleted. Drop it here too so copying + // such orphaned rows can't fail with an fk_inbounds_client_stats violation. + if err := dst.Exec("ALTER TABLE client_traffics DROP CONSTRAINT IF EXISTS fk_inbounds_client_stats").Error; err != nil { + return fmt.Errorf("drop legacy foreign key: %w", err) + } + // Empty the destination tables so the migration is idempotent: a fresh // PostgreSQL DB already holds an auto-seeded admin (id=1) from any prior // panel start, and a partially-failed earlier run leaves rows behind. Either diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e72950fc..9eff4db4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,7 +17,7 @@ "axios": "^1.17.0", "codemirror": "^6.0.2", "dayjs": "^1.11.21", - "i18next": "^26.3.0", + "i18next": "^26.3.1", "otpauth": "^9.5.1", "persian-calendar-suite": "^1.5.5", "qs": "^6.15.2", @@ -1934,9 +1934,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1954,9 +1951,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1974,9 +1968,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1994,9 +1985,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2014,9 +2002,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2034,9 +2019,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5087,9 +5069,9 @@ } }, "node_modules/i18next": { - "version": "26.3.0", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.0.tgz", - "integrity": "sha512-gHSgGpUXVmuqE2El1W61DmxeyeTlFfZgdJRWMo9jScAn5pu7TuTuiccb1zh3E2J9hEBVGJ23+96x0ieBhfuIHA==", + "version": "26.3.1", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz", + "integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==", "funding": [ { "type": "individual", @@ -5615,9 +5597,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5639,9 +5618,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5663,9 +5639,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5687,9 +5660,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/frontend/package.json b/frontend/package.json index 1d3452bc..1589ea28 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,7 +29,7 @@ "axios": "^1.17.0", "codemirror": "^6.0.2", "dayjs": "^1.11.21", - "i18next": "^26.3.0", + "i18next": "^26.3.1", "otpauth": "^9.5.1", "persian-calendar-suite": "^1.5.5", "qs": "^6.15.2", diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index dab418d5..1beb1632 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -1495,6 +1495,36 @@ } } }, + "/panel/api/server/getMigration": { + "get": { + "tags": [ + "Server" + ], + "summary": "Stream a cross-engine migration file as an attachment: a .dump (SQL text) on SQLite, or a .db SQLite database built from the live data on PostgreSQL.", + "operationId": "get_panel_api_server_getMigration", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": {} + } + } + } + } + } + } + } + }, "/panel/api/server/getNewUUID": { "get": { "tags": [ @@ -5761,7 +5791,7 @@ "tags": [ "Subscription Server" ], - "summary": "Return subscription as a Clash/Mihomo-compatible YAML config. Only when Clash subscription is enabled in settings. Default path: /clash/:subid.", + "summary": "Return subscription as a Clash/Mihomo-compatible YAML config, including configured global Clash routing rules. Only when Clash subscription is enabled in settings. Default path: /clash/:subid.", "operationId": "get_clashPath_subid", "parameters": [ { diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index 23da8193..ccb8ebab 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -34,7 +34,9 @@ export interface AllSetting { subAnnounce: string; subCertFile: string; subClashEnable: boolean; + subClashEnableRouting: boolean; subClashPath: string; + subClashRules: string; subClashURI: string; subDomain: string; subEmailInRemark: boolean; @@ -42,9 +44,8 @@ export interface AllSetting { subEnableRouting: boolean; subEncrypt: boolean; subJsonEnable: boolean; - subJsonFragment: string; + subJsonFinalMask: string; subJsonMux: string; - subJsonNoises: string; subJsonPath: string; subJsonRules: string; subJsonURI: string; @@ -121,7 +122,9 @@ export interface AllSettingView { subAnnounce: string; subCertFile: string; subClashEnable: boolean; + subClashEnableRouting: boolean; subClashPath: string; + subClashRules: string; subClashURI: string; subDomain: string; subEmailInRemark: boolean; @@ -129,9 +132,8 @@ export interface AllSettingView { subEnableRouting: boolean; subEncrypt: boolean; subJsonEnable: boolean; - subJsonFragment: string; + subJsonFinalMask: string; subJsonMux: string; - subJsonNoises: string; subJsonPath: string; subJsonRules: string; subJsonURI: string; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index e64c26f9..68fdf192 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -36,7 +36,9 @@ export const AllSettingSchema = z.object({ subAnnounce: z.string(), subCertFile: z.string(), subClashEnable: z.boolean(), + subClashEnableRouting: z.boolean(), subClashPath: z.string(), + subClashRules: z.string(), subClashURI: z.string(), subDomain: z.string(), subEmailInRemark: z.boolean(), @@ -44,9 +46,8 @@ export const AllSettingSchema = z.object({ subEnableRouting: z.boolean(), subEncrypt: z.boolean(), subJsonEnable: z.boolean(), - subJsonFragment: z.string(), + subJsonFinalMask: z.string(), subJsonMux: z.string(), - subJsonNoises: z.string(), subJsonPath: z.string(), subJsonRules: z.string(), subJsonURI: z.string(), @@ -124,7 +125,9 @@ export const AllSettingViewSchema = z.object({ subAnnounce: z.string(), subCertFile: z.string(), subClashEnable: z.boolean(), + subClashEnableRouting: z.boolean(), subClashPath: z.string(), + subClashRules: z.string(), subClashURI: z.string(), subDomain: z.string(), subEmailInRemark: z.boolean(), @@ -132,9 +135,8 @@ export const AllSettingViewSchema = z.object({ subEnableRouting: z.boolean(), subEncrypt: z.boolean(), subJsonEnable: z.boolean(), - subJsonFragment: z.string(), + subJsonFinalMask: z.string(), subJsonMux: z.string(), - subJsonNoises: z.string(), subJsonPath: z.string(), subJsonRules: z.string(), subJsonURI: z.string(), diff --git a/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx b/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx index f400620f..457f9a85 100644 --- a/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx +++ b/frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx @@ -6,21 +6,15 @@ import type { NamePath } from 'antd/es/form/interface'; import { RandomUtil } from '@/utils'; import { OutboundProtocols } from '@/schemas/primitives'; -// Pattern A FinalMaskForm. Renders a Fragment of Form.Items at absolute -// paths under `name`; the parent modal owns the Form instance. -// -// Naming convention inside Form.List: AntD prefixes Form.Item `name` -// with the Form.List's own `name`. So Form.Items inside the render -// prop use RELATIVE paths (e.g. `[field.name, 'type']`). Nested -// Form.Lists also use relative names. Using absolute paths here would -// double up the prefix and silently route reads/writes to the wrong -// storage path. - export interface FinalMaskFormProps { name: NamePath; network: string; protocol: string; form: FormInstance; + // When true, all sections (TCP / UDP / QUIC) are shown regardless of + // network/protocol. Used by the global sub-JSON finalmask editor where + // the masks apply to every stream rather than one specific transport. + showAll?: boolean; } const TCP_NETWORKS = ['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp']; @@ -99,12 +93,12 @@ function defaultUdpHop(): Record { return { ports: '20000-50000', interval: '5-10' }; } -export default function FinalMaskForm({ name, network, protocol, form }: FinalMaskFormProps) { +export default function FinalMaskForm({ name, network, protocol, form, showAll = false }: FinalMaskFormProps) { const base = asPath(name); const isHysteria = protocol === OutboundProtocols.Hysteria || protocol === 'hysteria'; - const showTcp = TCP_NETWORKS.includes(network); - const showUdp = isHysteria || network === 'kcp'; - const showQuic = isHysteria || network === 'xhttp'; + const showTcp = showAll || TCP_NETWORKS.includes(network); + const showUdp = showAll || isHysteria || network === 'kcp'; + const showQuic = showAll || isHysteria || network === 'xhttp'; const quicParams = Form.useWatch([...base, 'quicParams'], { form, preserve: true }); const hasQuicParams = quicParams != null; @@ -392,13 +386,13 @@ function UdpMaskItem({ const options = isHysteria ? [{ value: 'salamander', label: 'Salamander (Hysteria2)' }] : [ - { value: 'mkcp-legacy', label: 'mKCP Legacy' }, - { value: 'xdns', label: 'xDNS' }, - { value: 'xicmp', label: 'xICMP' }, - { value: 'realm', label: 'Realm' }, - { value: 'header-custom', label: 'Header Custom' }, - { value: 'noise', label: 'Noise' }, - ]; + { value: 'mkcp-legacy', label: 'mKCP Legacy' }, + { value: 'xdns', label: 'xDNS' }, + { value: 'xicmp', label: 'xICMP' }, + { value: 'realm', label: 'Realm' }, + { value: 'header-custom', label: 'Header Custom' }, + { value: 'noise', label: 'Noise' }, + ]; return (
diff --git a/frontend/src/models/setting.ts b/frontend/src/models/setting.ts index fcbe1ec1..047dcca6 100644 --- a/frontend/src/models/setting.ts +++ b/frontend/src/models/setting.ts @@ -55,10 +55,11 @@ export class AllSetting { subURI = ''; subJsonURI = ''; subClashURI = ''; - subJsonFragment = ''; - subJsonNoises = ''; + subClashEnableRouting = false; + subClashRules = ''; subJsonMux = ''; subJsonRules = ''; + subJsonFinalMask = ''; timeLocation = 'Local'; diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index 2233e211..3d6e4c02 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -1114,7 +1114,7 @@ export const sections: readonly Section[] = [ { method: 'GET', path: '/{clashPath}:subid', - summary: 'Return subscription as a Clash/Mihomo-compatible YAML config. Only when Clash subscription is enabled in settings. Default path: /clash/:subid.', + summary: 'Return subscription as a Clash/Mihomo-compatible YAML config, including configured global Clash routing rules. Only when Clash subscription is enabled in settings. Default path: /clash/:subid.', params: [ { name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' }, ], diff --git a/frontend/src/pages/clients/ClientBulkAddModal.tsx b/frontend/src/pages/clients/ClientBulkAddModal.tsx index aae6aeb4..b5aaf082 100644 --- a/frontend/src/pages/clients/ClientBulkAddModal.tsx +++ b/frontend/src/pages/clients/ClientBulkAddModal.tsx @@ -249,7 +249,7 @@ export default function ClientBulkAddModal({ )} {form.emailMethod < 2 && ( - update('quantity', Number(v) || 1)} /> + update('quantity', Number(v) || 1)} /> )} diff --git a/frontend/src/pages/clients/ClientsPage.tsx b/frontend/src/pages/clients/ClientsPage.tsx index 6d6740ff..eecfd828 100644 --- a/frontend/src/pages/clients/ClientsPage.tsx +++ b/frontend/src/pages/clients/ClientsPage.tsx @@ -71,6 +71,7 @@ import type { ClientFilters } from './filters'; import './ClientsPage.css'; const FILTER_STATE_KEY = 'clientsFilterState'; +const DISABLED_PAGE_SIZE = 200; function UngroupIcon() { return ( @@ -276,10 +277,7 @@ export default function ClientsPage() { const activeCount = activeFilterCount(filters); useEffect(() => { - if (pageSize > 0) { - - setTablePageSize(pageSize); - } + setTablePageSize(pageSize > 0 ? pageSize : DISABLED_PAGE_SIZE); }, [pageSize]); const onlineSet = useMemo(() => new Set(onlines || []), [onlines]); diff --git a/frontend/src/pages/settings/SubJsonFinalMaskForm.tsx b/frontend/src/pages/settings/SubJsonFinalMaskForm.tsx new file mode 100644 index 00000000..09f588a7 --- /dev/null +++ b/frontend/src/pages/settings/SubJsonFinalMaskForm.tsx @@ -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).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 ( +
+ + + ); +} diff --git a/frontend/src/pages/settings/SubscriptionFormatsTab.tsx b/frontend/src/pages/settings/SubscriptionFormatsTab.tsx index 7cfe45f1..14c9aafe 100644 --- a/frontend/src/pages/settings/SubscriptionFormatsTab.tsx +++ b/frontend/src/pages/settings/SubscriptionFormatsTab.tsx @@ -1,8 +1,6 @@ import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { - Button, - Card, Input, InputNumber, Select, @@ -10,19 +8,17 @@ import { Tabs, } from 'antd'; import { - DeleteOutlined, PartitionOutlined, - PlusOutlined, - ScissorOutlined, + RocketOutlined, SendOutlined, SettingOutlined, - ThunderboltOutlined, } from '@ant-design/icons'; import type { AllSetting } from '@/models/setting'; import { SettingListItem } from '@/components/ui'; import { useMediaQuery } from '@/hooks/useMediaQuery'; import { catTabLabel } from './catTabLabel'; import { sanitizePath, normalizePath } from './uriPath'; +import SubJsonFinalMaskForm from './SubJsonFinalMaskForm'; import './SubscriptionFormatsTab.css'; interface SubscriptionFormatsTabProps { @@ -30,15 +26,6 @@ interface SubscriptionFormatsTabProps { updateSetting: (patch: Partial) => void; } -const DEFAULT_FRAGMENT = { - packets: 'tlshello', - length: '100-200', - interval: '10-20', - maxSplit: '300-400', -}; -const DEFAULT_NOISES: { type: string; packet: string; delay: string; applyTo: string }[] = [ - { type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ip' }, -]; const DEFAULT_MUX = { enabled: true, concurrency: 8, @@ -85,55 +72,9 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su const { t } = useTranslation(); const { isMobile } = useMediaQuery(); - const fragment = allSetting.subJsonFragment !== ''; - const noisesEnabled = allSetting.subJsonNoises !== ''; const muxEnabled = allSetting.subJsonMux !== ''; const directEnabled = allSetting.subJsonRules !== ''; - const fragmentObj = useMemo( - () => (fragment ? readJson(allSetting.subJsonFragment, DEFAULT_FRAGMENT) : DEFAULT_FRAGMENT), - [allSetting.subJsonFragment, fragment], - ); - - function setFragmentEnabled(v: boolean) { - updateSetting({ subJsonFragment: v ? JSON.stringify(DEFAULT_FRAGMENT) : '' }); - } - - function setFragmentField(key: K, value: string) { - if (value === '') return; - const next = { ...fragmentObj, [key]: value }; - updateSetting({ subJsonFragment: JSON.stringify(next) }); - } - - const noisesArray = useMemo( - () => (noisesEnabled ? readJson(allSetting.subJsonNoises, DEFAULT_NOISES) : []), - [allSetting.subJsonNoises, noisesEnabled], - ); - - function setNoisesEnabled(v: boolean) { - updateSetting({ subJsonNoises: v ? JSON.stringify(DEFAULT_NOISES) : '' }); - } - - function setNoisesArray(next: typeof DEFAULT_NOISES) { - if (noisesEnabled) updateSetting({ subJsonNoises: JSON.stringify(next) }); - } - - function addNoise() { - setNoisesArray([...noisesArray, { ...DEFAULT_NOISES[0] }]); - } - - function removeNoise(index: number) { - const next = [...noisesArray]; - next.splice(index, 1); - setNoisesArray(next); - } - - function updateNoiseField(index: number, field: keyof typeof DEFAULT_NOISES[number], value: string) { - const next = [...noisesArray]; - next[index] = { ...next[index], [field]: value }; - setNoisesArray(next); - } - const muxObj = useMemo( () => (muxEnabled ? readJson(allSetting.subJsonMux, DEFAULT_MUX) : DEFAULT_MUX), [allSetting.subJsonMux, muxEnabled], @@ -251,98 +192,19 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su }, { key: '2', - label: catTabLabel(, t('pages.settings.fragment'), isMobile), + label: catTabLabel(, t('pages.settings.subFormats.finalMask'), isMobile), children: ( <> - - - - {fragment && ( -
- - setFragmentField('packets', e.target.value)} /> - - - setFragmentField('length', e.target.value)} /> - - - setFragmentField('interval', e.target.value)} /> - - - setFragmentField('maxSplit', e.target.value)} /> - -
- )} + + updateSetting({ subJsonFinalMask: v })} + /> ), }, { key: '3', - label: catTabLabel(, t('pages.settings.subFormats.noises'), isMobile), - children: ( - <> - - - - {noisesEnabled && ( -
- {noisesArray.map((noise, index) => ( - 1 ? ( -