mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-05 20:55:29 +03:00
Compare commits
23 Commits
ed9686bf29
...
v3.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e928a1ce0 | |||
| e211a5cc47 | |||
| 77dffe9a85 | |||
| 54fc0fd47c | |||
| 30b611614b | |||
| 44f2f426d8 | |||
| c4a1139d3f | |||
| 476bec451d | |||
| f905c2dcec | |||
| 30f6bc1833 | |||
| 2c95e29297 | |||
| 814cda3fb4 | |||
| affcf6c422 | |||
| cbd2940a63 | |||
| e6bef229ae | |||
| 975b1f1acc | |||
| 6aa87f4e57 | |||
| 200ea09157 | |||
| fc625d8f66 | |||
| c4448f4ea8 | |||
| 3b731cd657 | |||
| 1c789c3e4d | |||
| 201d4731de |
@@ -118,7 +118,7 @@ jobs:
|
||||
cd x-ui/bin
|
||||
|
||||
# Download dependencies
|
||||
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.6.27/"
|
||||
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.7.11/"
|
||||
if [ "${{ matrix.platform }}" == "amd64" ]; then
|
||||
wget -q ${Xray_URL}Xray-linux-64.zip
|
||||
unzip Xray-linux-64.zip
|
||||
@@ -273,7 +273,7 @@ jobs:
|
||||
cd x-ui\bin
|
||||
|
||||
# Download Xray for Windows
|
||||
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.6.27/"
|
||||
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.7.11/"
|
||||
Invoke-WebRequest -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
|
||||
Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
|
||||
Remove-Item "Xray-windows-64.zip"
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ if [ -z "$MTG_MULTI_VER" ]; then
|
||||
fi
|
||||
mkdir -p build/bin
|
||||
cd build/bin
|
||||
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.6.27/Xray-linux-${ARCH}.zip"
|
||||
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.7.11/Xray-linux-${ARCH}.zip"
|
||||
unzip "Xray-linux-${ARCH}.zip"
|
||||
rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
|
||||
mv xray "xray-linux-${FNAME}"
|
||||
|
||||
@@ -122,7 +122,9 @@ Only valid when the protocol is **Hysteria2**.
|
||||
disguise transports that don't carry TLS (like mKCP) or add a second skin on top of
|
||||
TLS. Masks are configured per direction:
|
||||
|
||||
- **TCP masks** — `fragment`, `sudoku`, `header-custom`.
|
||||
- **TCP masks** — `fragment`, `sudoku`, `header-custom`, `xmc` (disguises the
|
||||
stream as Minecraft protocol traffic; requires a password, with optional
|
||||
hostname and player usernames).
|
||||
- **UDP masks** — `salamander`, `mkcp-legacy`, `header-custom`, `xdns`, `xicmp`,
|
||||
`noise`, `sudoku`, `realm`. (`mkcp-legacy` reproduces the old mKCP header
|
||||
obfuscation.)
|
||||
|
||||
@@ -123,7 +123,9 @@ icon: Network
|
||||
انتقالهایی را که TLS حمل نمیکنند (مانند mKCP) استتار کند یا پوستهای دوم روی TLS
|
||||
بیفزاید. ماسکها برای هر جهت پیکربندی میشوند:
|
||||
|
||||
- **ماسکهای TCP** — `fragment`، `sudoku`، `header-custom`.
|
||||
- **ماسکهای TCP** — `fragment`، `sudoku`، `header-custom`، `xmc` (ترافیک را به شکل
|
||||
پروتکل Minecraft استتار میکند؛ گذرواژه الزامی است و نام میزبان و نامهای بازیکن
|
||||
اختیاریاند).
|
||||
- **ماسکهای UDP** — `salamander`، `mkcp-legacy`، `header-custom`، `xdns`، `xicmp`،
|
||||
`noise`، `sudoku`، `realm`. (`mkcp-legacy` همان مبهمسازی قدیمی هدر mKCP را
|
||||
بازتولید میکند.)
|
||||
|
||||
@@ -125,7 +125,9 @@ HTTPUpgrade — это одноразовый HTTP/1.1 `Upgrade` без фрей
|
||||
поэтому он может замаскировать транспорты, не несущие TLS (например, mKCP), или
|
||||
добавить вторую оболочку поверх TLS. Маски настраиваются по направлениям:
|
||||
|
||||
- **TCP-маски** — `fragment`, `sudoku`, `header-custom`.
|
||||
- **TCP-маски** — `fragment`, `sudoku`, `header-custom`, `xmc` (маскирует поток
|
||||
под трафик протокола Minecraft; требуется пароль, имя хоста и имена игроков
|
||||
опциональны).
|
||||
- **UDP-маски** — `salamander`, `mkcp-legacy`, `header-custom`, `xdns`, `xicmp`,
|
||||
`noise`, `sudoku`, `realm`. (`mkcp-legacy` воспроизводит старую обфускацию
|
||||
заголовка mKCP.)
|
||||
|
||||
@@ -117,7 +117,8 @@ Session-ID 字段(`sessionIDPlacement`、`sessionIDKey`、`sessionIDTable`、
|
||||
**FinalMask** 在传输方式和安全层**之后**包裹流量,因此它既能伪装那些承载不了 TLS 的
|
||||
传输(如 mKCP),也能在 TLS 之上再加一层外壳。掩码按方向分别配置:
|
||||
|
||||
- **TCP 掩码** — `fragment`、`sudoku`、`header-custom`。
|
||||
- **TCP 掩码** — `fragment`、`sudoku`、`header-custom`、`xmc`(把流量伪装成
|
||||
Minecraft 协议;密码必填,主机名和玩家用户名可选)。
|
||||
- **UDP 掩码** — `salamander`、`mkcp-legacy`、`header-custom`、`xdns`、`xicmp`、
|
||||
`noise`、`sudoku`、`realm`。(`mkcp-legacy` 重现旧版 mKCP 的头部混淆。)
|
||||
- **QUIC 参数** — 拥塞控制(`reno`、`bbr`、`brutal`、`force-brutal`)、Brutal 上/下行
|
||||
|
||||
Generated
+141
-120
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "3x-ui-frontend",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "3x-ui-frontend",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.3",
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.3.2",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
@@ -18,13 +18,13 @@
|
||||
"antd": "^6.5.0",
|
||||
"codemirror": "^6.0.2",
|
||||
"dayjs": "^1.11.21",
|
||||
"i18next": "^26.3.5",
|
||||
"i18next": "^26.3.6",
|
||||
"otpauth": "^9.5.1",
|
||||
"persian-calendar-suite": "^1.5.5",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-hook-form": "^7.81.0",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-i18next": "^17.0.9",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"swagger-ui-react": "^5.32.8",
|
||||
"uplot": "^1.6.32",
|
||||
@@ -32,9 +32,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@storybook/addon-a11y": "^10.4.6",
|
||||
"@storybook/addon-docs": "^10.4.6",
|
||||
"@storybook/react-vite": "^10.4.6",
|
||||
"@storybook/addon-a11y": "^10.5.0",
|
||||
"@storybook/addon-docs": "^10.5.0",
|
||||
"@storybook/react-vite": "^10.5.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.2.17",
|
||||
@@ -42,7 +42,7 @@
|
||||
"@types/swagger-ui-react": "^5.18.0",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"eslint": "^10.6.0",
|
||||
"eslint": "^10.7.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"globals": "^17.7.0",
|
||||
@@ -50,7 +50,7 @@
|
||||
"jsdom": "^29.1.1",
|
||||
"lint-staged": "^17.0.8",
|
||||
"msw": "^2.15.0",
|
||||
"storybook": "^10.4.6",
|
||||
"storybook": "^10.5.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.63.0",
|
||||
"vite": "8.1.4",
|
||||
@@ -2572,13 +2572,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rc-component/dropdown": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/dropdown/-/dropdown-1.0.2.tgz",
|
||||
"integrity": "sha512-6PY2ecUSYhDPhkNHHb4wfeAya04WhpmUSKzdR60G+kMNVUCX2vjT/AgTS0Lz0I/K6xrPMJ3enQbwVpeN3sHCgg==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/dropdown/-/dropdown-1.0.3.tgz",
|
||||
"integrity": "sha512-YTST/N6kpqpDz3IMuM/PSSZnrDpSOA6dgHv12gPA90ZTSLv2CoqkZ0+9NtwTY6BeO7dstPblSic2QJg7dSFy/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@rc-component/trigger": "^3.0.0",
|
||||
"@rc-component/util": "^1.2.1",
|
||||
"@rc-component/util": "^1.11.1",
|
||||
"clsx": "^2.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -3084,15 +3084,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rc-component/trigger": {
|
||||
"version": "3.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-3.9.1.tgz",
|
||||
"integrity": "sha512-LNsYvz60mrLJ/kRvKcHE7boUvcQfVMCfRqZ71x3Fo9AOiZ1KKIEqkzMA8DNvz2V3Bcvir/vwQNn7JF1NPODQ7Q==",
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-3.10.0.tgz",
|
||||
"integrity": "sha512-uC3QSG7Ax3qLOE5Q2jLqJCJc4iBtJEHzNTPhqGvlRvRcU8x8CT5moIavRVe24YSQKCp2/D1GSq7y76SCSheuVA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@rc-component/motion": "^1.1.4",
|
||||
"@rc-component/portal": "^2.2.0",
|
||||
"@rc-component/resize-observer": "^1.1.1",
|
||||
"@rc-component/util": "^1.2.1",
|
||||
"@rc-component/motion": "^1.3.3",
|
||||
"@rc-component/portal": "^2.2.1",
|
||||
"@rc-component/resize-observer": "^1.1.2",
|
||||
"@rc-component/util": "^1.11.1",
|
||||
"clsx": "^2.1.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -3132,12 +3132,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rc-component/virtual-list": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/virtual-list/-/virtual-list-1.2.0.tgz",
|
||||
"integrity": "sha512-iavRm1Jo4GDbASQwdGa7jFyk93RvSOo9xHyBT4QL1pgFJj/Fdf1G+3RErH7/7BmAMvx2AkF62mjGYxDbXsK9TQ==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/virtual-list/-/virtual-list-1.3.2.tgz",
|
||||
"integrity": "sha512-/smuvWBFdP/Is9QuNDKynD0+T3XTXWFyNXXNKJ4sno8CE3bTOK8sfgYmQJtYwLUNX+lv0Ytd+PMshgpmdReq5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.0",
|
||||
"@babel/runtime": "^8.0.0",
|
||||
"@rc-component/resize-observer": "^1.0.1",
|
||||
"@rc-component/util": "^1.4.0",
|
||||
"clsx": "^2.1.1"
|
||||
@@ -3150,6 +3150,12 @@
|
||||
"react-dom": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rc-component/virtual-list/node_modules/@babel/runtime": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-8.0.0.tgz",
|
||||
"integrity": "sha512-sL6cvO2IfkSu/iU+zs2S/w01B7A8V7suXSIKEN4hPFFdZoiPGxrj5pAG0lCaqLWiEIrjKzdznIWuaLcxPR53qw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
|
||||
@@ -3510,9 +3516,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@storybook/addon-a11y": {
|
||||
"version": "10.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.4.6.tgz",
|
||||
"integrity": "sha512-XCJy+f0DFOiCgUU9knRDlLDxVFI+AAQ3/wE/NF85zB9iDPPS2DwkSN+mas3zDgHt66zhN8Cq3/UiyCDUweV9Zw==",
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.5.0.tgz",
|
||||
"integrity": "sha512-AguqTsYS2NX20AF1vWt6IonbkpMXur30/dAOOYltbEW7uKmPxBNtjuiBmYB2WI4aFN4r8jje4bbDGk/Yfq58hQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3524,20 +3530,20 @@
|
||||
"url": "https://opencollective.com/storybook"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"storybook": "^10.4.6"
|
||||
"storybook": "^10.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/addon-docs": {
|
||||
"version": "10.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.4.6.tgz",
|
||||
"integrity": "sha512-aWAfP5JMiT5a3zBJizwroCRzOCqZwDTJmvsYvwMD3ilIEa/kT1vhf6Xrbk4XIPhDwbh8Hpb/Gfnka1xBYEISWg==",
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.0.tgz",
|
||||
"integrity": "sha512-kbZGdX+mysiY4xezhpcFEwzQ1zSXcMhSS3u09Qt8+w5XetCAMMSv/yAxzpi6z+YoH+EdQ53e1q3MFtPUkzkfUA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"@storybook/csf-plugin": "10.4.6",
|
||||
"@storybook/csf-plugin": "10.5.0",
|
||||
"@storybook/icons": "^2.0.2",
|
||||
"@storybook/react-dom-shim": "10.4.6",
|
||||
"@storybook/react-dom-shim": "10.5.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"ts-dedent": "^2.0.0"
|
||||
@@ -3548,7 +3554,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "^10.4.6"
|
||||
"storybook": "^10.5.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
@@ -3557,13 +3563,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/builder-vite": {
|
||||
"version": "10.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.4.6.tgz",
|
||||
"integrity": "sha512-BHBtD81HiXUiDQz/CaFynLtWmm7AFUQn8VnXuHipZ8KlnUANopa4yqdVuy/Gwz8ub254uFI5NMZsW/KlgWNgNg==",
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.5.0.tgz",
|
||||
"integrity": "sha512-KXlifNIThDgS84KqVAJXyilool8OLTWp6DGoO9h5bHM2IPLe7UcdKfOzMUBkQ807mWBk4aW1yGEekj7kC2dvmg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@storybook/csf-plugin": "10.4.6",
|
||||
"@storybook/csf-plugin": "10.5.0",
|
||||
"ts-dedent": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
@@ -3571,14 +3577,14 @@
|
||||
"url": "https://opencollective.com/storybook"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"storybook": "^10.4.6",
|
||||
"storybook": "^10.5.0",
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/csf-plugin": {
|
||||
"version": "10.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.4.6.tgz",
|
||||
"integrity": "sha512-NILLxDqpA/JR/AazGWpsz+4fadJwRU4uhHephGtYpVOWnQA/DkJfKT6zpcJVq8+QA8A2zKMLX3GVKsXIrxjuDA==",
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.0.tgz",
|
||||
"integrity": "sha512-R7VFw6FnDZTWct0ekOcFnTfDgdHnnAuQW+AbGG9A9IZPvyH9uLJivK7L86+r9MITRrO2+v81HaFDsT/OFk6ZkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3591,7 +3597,7 @@
|
||||
"peerDependencies": {
|
||||
"esbuild": "*",
|
||||
"rollup": "*",
|
||||
"storybook": "^10.4.6",
|
||||
"storybook": "^10.5.0",
|
||||
"vite": "*",
|
||||
"webpack": "*"
|
||||
},
|
||||
@@ -3628,14 +3634,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react": {
|
||||
"version": "10.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.4.6.tgz",
|
||||
"integrity": "sha512-9Y7YecrVFe1/01KYjfOLxVqTg2Aq+IO6TEv6sC2U0PfD0AWCSCmQ91QqgBpN/XW4aFFWoiZNinyXMUlU8zxy2w==",
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.0.tgz",
|
||||
"integrity": "sha512-2IhddiREy7NqAqnFsEOfW6HBG7azIcLxhRQYploSsaemOncR29xhzvAZsG+xlWgrtvxv4h3fYQTTR4TNn8CgPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@storybook/global": "^5.0.0",
|
||||
"@storybook/react-dom-shim": "10.4.6",
|
||||
"@storybook/react-dom-shim": "10.5.0",
|
||||
"react-docgen": "^8.0.2",
|
||||
"react-docgen-typescript": "^2.2.2"
|
||||
},
|
||||
@@ -3648,7 +3654,7 @@
|
||||
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "^10.4.6",
|
||||
"storybook": "^10.5.0",
|
||||
"typescript": ">= 4.9.x"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
@@ -3664,9 +3670,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-dom-shim": {
|
||||
"version": "10.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.4.6.tgz",
|
||||
"integrity": "sha512-iGNmKzrq9vgl2PDrYAnZKI+yvac3Ym+lJXXuQaqlFRS23zA5MNm4EBX+rAG7WulqchoK6NaZ0KQOs2mAgEpTMg==",
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.0.tgz",
|
||||
"integrity": "sha512-GwGA6zDj4Cfw6vGsdfPKfF39L0W6solNbbnjdjGa57m2ooASkidLhrXo2wgC9wh3o/jcfonmYPDRGY6sEhGDtg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -3678,7 +3684,7 @@
|
||||
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "^10.4.6"
|
||||
"storybook": "^10.5.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
@@ -3690,19 +3696,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-vite": {
|
||||
"version": "10.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.4.6.tgz",
|
||||
"integrity": "sha512-0arEQtybqGYXHbXpTot+Wv9YtG+V5Vp43QayXavPKQ20M8mpEzhyCPKd0EhqMGSC1Z1UEt0hm365WUBhI9LfKA==",
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.5.0.tgz",
|
||||
"integrity": "sha512-d9n/I3pViscXpJYT9JHxcpxVSam14HsHOr2wBqTQTiRtaiH4xSsT00HTEGm6CEZTyq/npTJyV0Qday9XhrtiDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@joshwooding/vite-plugin-react-docgen-typescript": "^0.7.0",
|
||||
"@rollup/pluginutils": "^5.0.2",
|
||||
"@storybook/builder-vite": "10.4.6",
|
||||
"@storybook/react": "10.4.6",
|
||||
"@storybook/builder-vite": "10.5.0",
|
||||
"@storybook/react": "10.5.0",
|
||||
"empathic": "^2.0.0",
|
||||
"magic-string": "^0.30.0",
|
||||
"react-docgen": "^8.0.0",
|
||||
"react-docgen": "^8.0.2",
|
||||
"resolve": "^1.22.8",
|
||||
"tsconfig-paths": "^4.2.0"
|
||||
},
|
||||
@@ -3713,8 +3719,14 @@
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "^10.4.6",
|
||||
"storybook": "^10.5.0",
|
||||
"typescript": ">= 4.9.x",
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@swagger-api/apidom-ast": {
|
||||
@@ -4621,9 +4633,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/hast": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
|
||||
"integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz",
|
||||
"integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/unist": "*"
|
||||
@@ -4771,9 +4783,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
|
||||
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz",
|
||||
"integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -5658,9 +5670,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.42",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz",
|
||||
"integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==",
|
||||
"version": "2.10.43",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz",
|
||||
"integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -5693,9 +5705,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.5",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz",
|
||||
"integrity": "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==",
|
||||
"version": "4.28.6",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz",
|
||||
"integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -5714,9 +5726,9 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.42",
|
||||
"caniuse-lite": "^1.0.30001800",
|
||||
"electron-to-chromium": "^1.5.387",
|
||||
"node-releases": "^2.0.50",
|
||||
"caniuse-lite": "^1.0.30001803",
|
||||
"electron-to-chromium": "^1.5.389",
|
||||
"node-releases": "^2.0.51",
|
||||
"update-browserslist-db": "^1.2.3"
|
||||
},
|
||||
"bin": {
|
||||
@@ -5814,9 +5826,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001803",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001803.tgz",
|
||||
"integrity": "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==",
|
||||
"version": "1.0.30001805",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001805.tgz",
|
||||
"integrity": "sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -6481,9 +6493,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.11",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
|
||||
"integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
|
||||
"version": "3.4.12",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz",
|
||||
"integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
@@ -6669,9 +6681,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz",
|
||||
"integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==",
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
|
||||
"integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -6802,9 +6814,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.6.0.tgz",
|
||||
"integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==",
|
||||
"version": "10.7.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.7.0.tgz",
|
||||
"integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -7741,9 +7753,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.3.5",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.5.tgz",
|
||||
"integrity": "sha512-cd5lgkvjYQAPDfP3bbrAIE1wJmjTIlsrsjDqlvsHn4wFAerP5O0NfAe8RiDcXRfukN5ETe7Tlmfp6DkyuqDn6Q==",
|
||||
"version": "26.3.6",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
|
||||
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -7760,7 +7772,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"typescript": "^5 || ^6"
|
||||
"typescript": "^5 || ^6 || ^7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
@@ -8577,6 +8589,13 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonc-parser": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
|
||||
"integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsx-ast-utils": {
|
||||
"version": "3.3.5",
|
||||
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
||||
@@ -9346,9 +9365,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.15",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
|
||||
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -9409,9 +9428,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.50",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz",
|
||||
"integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==",
|
||||
"version": "2.0.51",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
|
||||
"integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -9893,9 +9912,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.16",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
|
||||
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
|
||||
"version": "8.5.17",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.17.tgz",
|
||||
"integrity": "sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -10158,9 +10177,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.8",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
|
||||
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
|
||||
"version": "17.0.9",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.9.tgz",
|
||||
"integrity": "sha512-buLzOSqHtXxjf+qgSrLWNTXVZ1jSwO6kUv3uJqSP1roGBPgNnbhFm7OmdVwWcgf2gIbUyP0J333uPyx+Btsi3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
@@ -10170,7 +10189,7 @@
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 26.2.0",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5 || ^6"
|
||||
"typescript": "^5 || ^6 || ^7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
@@ -10754,9 +10773,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.1.tgz",
|
||||
"integrity": "sha512-vM9SUhjsUYs6UeJUmygc5Ofm5eQGe85riob5ju6XCgFGJI5PLV4nrDAQpQjd+LkFBpAkADn5BQQpZ9EUNkyLuA==",
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz",
|
||||
"integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -11062,20 +11081,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/storybook": {
|
||||
"version": "10.4.6",
|
||||
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.4.6.tgz",
|
||||
"integrity": "sha512-6wkA6LxfDSSilloITsrFOJfsnw0mDUP2h8Ls+lRt8oRsudtz2RWFhLv+Toiwg6NW7hUpdTDc2hzR7DztJid6+A==",
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.0.tgz",
|
||||
"integrity": "sha512-dRhM/kSSvHQR8DmZO41v5sJuz9U6zDjjR2gRBTgZN2RBSXbmF0Brvgszrvvxyx2VfxuYKzhB+xumKwWkwlBtig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@storybook/global": "^5.0.0",
|
||||
"@storybook/icons": "^2.0.2",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@vitest/expect": "3.2.4",
|
||||
"@vitest/spy": "3.2.4",
|
||||
"@webcontainer/env": "^1.1.1",
|
||||
"esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0 || ^0.28.0",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"open": "^10.2.0",
|
||||
"oxc-parser": "^0.127.0",
|
||||
"oxc-resolver": "^11.19.1",
|
||||
@@ -11094,7 +11115,7 @@
|
||||
"peerDependencies": {
|
||||
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"prettier": "^2 || ^3",
|
||||
"vite-plus": "^0.1.15"
|
||||
"vite-plus": "^0.1.15 || ^0.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
@@ -11145,9 +11166,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "8.2.1",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz",
|
||||
"integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==",
|
||||
"version": "8.2.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
|
||||
"integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -11492,22 +11513,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "7.4.7",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.7.tgz",
|
||||
"integrity": "sha512-56L0/9HELHSsG1bFCzay8UoLxzRL7kpFf7Wl5q/kSYwiSJGACvro61xnKzPNM+SadxllzdtXsKDSXE7HPeqIAw==",
|
||||
"version": "7.4.8",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.8.tgz",
|
||||
"integrity": "sha512-htwgN/8KRB3z3vnC0BOETVh2m499g5GmyTK9Wq5JBLX3FNz6tSBveAd+fQhzy9hkjif8vy2jwDMR1sGhLtZl2A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tldts-core": "^7.4.7"
|
||||
"tldts-core": "^7.4.8"
|
||||
},
|
||||
"bin": {
|
||||
"tldts": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts-core": {
|
||||
"version": "7.4.7",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.7.tgz",
|
||||
"integrity": "sha512-rNlAI8fKn/JckBMUSbNL/ES2kmDiurWaE49l+ikwEc9A6lFR7gMx9AhgQMQKBK4H5w4pKLH64JzZfB99uRsGNQ==",
|
||||
"version": "7.4.8",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.8.tgz",
|
||||
"integrity": "sha512-c1P7u0EhACHj7lPy4MJm8iTFEU8+nB0LCtddH0fhP7noaVoXAqafMtOOeX+ulpuPBqnrRgRhw494RICT3mbhnw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "3x-ui-frontend",
|
||||
"private": true,
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.3",
|
||||
"type": "module",
|
||||
"description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).",
|
||||
"engines": {
|
||||
@@ -37,13 +37,13 @@
|
||||
"antd": "^6.5.0",
|
||||
"codemirror": "^6.0.2",
|
||||
"dayjs": "^1.11.21",
|
||||
"i18next": "^26.3.5",
|
||||
"i18next": "^26.3.6",
|
||||
"otpauth": "^9.5.1",
|
||||
"persian-calendar-suite": "^1.5.5",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-hook-form": "^7.81.0",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-i18next": "^17.0.9",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"swagger-ui-react": "^5.32.8",
|
||||
"uplot": "^1.6.32",
|
||||
@@ -51,9 +51,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@storybook/addon-a11y": "^10.4.6",
|
||||
"@storybook/addon-docs": "^10.4.6",
|
||||
"@storybook/react-vite": "^10.4.6",
|
||||
"@storybook/addon-a11y": "^10.5.0",
|
||||
"@storybook/addon-docs": "^10.5.0",
|
||||
"@storybook/react-vite": "^10.5.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.2.17",
|
||||
@@ -61,7 +61,7 @@
|
||||
"@types/swagger-ui-react": "^5.18.0",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"eslint": "^10.6.0",
|
||||
"eslint": "^10.7.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"globals": "^17.7.0",
|
||||
@@ -69,7 +69,7 @@
|
||||
"jsdom": "^29.1.1",
|
||||
"lint-staged": "^17.0.8",
|
||||
"msw": "^2.15.0",
|
||||
"storybook": "^10.4.6",
|
||||
"storybook": "^10.5.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.63.0",
|
||||
"vite": "8.1.4",
|
||||
|
||||
@@ -881,6 +881,7 @@
|
||||
"ApiToken": {
|
||||
"properties": {
|
||||
"createdAt": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"enabled": {
|
||||
@@ -910,6 +911,7 @@
|
||||
"properties": {
|
||||
"createdAt": {
|
||||
"example": 1736000000,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"enabled": {
|
||||
@@ -960,6 +962,7 @@
|
||||
},
|
||||
"created_at": {
|
||||
"description": "Creation timestamp",
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"email": {
|
||||
@@ -972,6 +975,7 @@
|
||||
},
|
||||
"expiryTime": {
|
||||
"description": "Expiration timestamp",
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"flow": {
|
||||
@@ -1033,14 +1037,17 @@
|
||||
},
|
||||
"tgId": {
|
||||
"description": "Telegram user ID for notifications",
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"totalGB": {
|
||||
"description": "Total traffic limit in GB",
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"updated_at": {
|
||||
"description": "Last update timestamp",
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
@@ -1064,6 +1071,7 @@
|
||||
"type": "integer"
|
||||
},
|
||||
"createdAt": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"flowOverride": {
|
||||
@@ -1096,6 +1104,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"createdAt": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"email": {
|
||||
@@ -1105,6 +1114,7 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"expiryTime": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"flow": {
|
||||
@@ -1148,12 +1158,15 @@
|
||||
"type": "string"
|
||||
},
|
||||
"tgId": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"totalGB": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"updatedAt": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"uuid": {
|
||||
@@ -1206,6 +1219,7 @@
|
||||
"properties": {
|
||||
"down": {
|
||||
"example": 2097152,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"email": {
|
||||
@@ -1218,6 +1232,7 @@
|
||||
},
|
||||
"expiryTime": {
|
||||
"example": 1735689600000,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"id": {
|
||||
@@ -1230,6 +1245,7 @@
|
||||
},
|
||||
"lastOnline": {
|
||||
"example": 1735680000000,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"reset": {
|
||||
@@ -1242,10 +1258,12 @@
|
||||
},
|
||||
"total": {
|
||||
"example": 10737418240,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"up": {
|
||||
"example": 1048576,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"uuid": {
|
||||
@@ -1316,6 +1334,7 @@
|
||||
"type": "array"
|
||||
},
|
||||
"createdAt": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"echConfigList": {
|
||||
@@ -1431,6 +1450,7 @@
|
||||
"type": "array"
|
||||
},
|
||||
"updatedAt": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"verifyPeerCertByName": {
|
||||
@@ -1660,6 +1680,7 @@
|
||||
},
|
||||
"down": {
|
||||
"description": "Download traffic in bytes",
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"enable": {
|
||||
@@ -1669,6 +1690,7 @@
|
||||
},
|
||||
"expiryTime": {
|
||||
"description": "Expiration timestamp",
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"fallbackParent": {
|
||||
@@ -1687,6 +1709,7 @@
|
||||
},
|
||||
"lastTrafficResetTime": {
|
||||
"description": "Last traffic reset timestamp",
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"listen": {
|
||||
@@ -1755,6 +1778,7 @@
|
||||
},
|
||||
"total": {
|
||||
"description": "Total traffic limit in bytes",
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"trafficReset": {
|
||||
@@ -1770,6 +1794,7 @@
|
||||
},
|
||||
"up": {
|
||||
"description": "Upload traffic in bytes",
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
@@ -1981,6 +2006,7 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"configDirtyAt": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"cpuPct": {
|
||||
@@ -1989,6 +2015,7 @@
|
||||
},
|
||||
"createdAt": {
|
||||
"example": 1700000000,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"depletedCount": {
|
||||
@@ -2034,6 +2061,7 @@
|
||||
"lastHeartbeat": {
|
||||
"description": "unix seconds, 0 = never",
|
||||
"example": 1700000000,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"latencyMs": {
|
||||
@@ -2050,10 +2078,12 @@
|
||||
},
|
||||
"netDown": {
|
||||
"example": 2097152,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"netUp": {
|
||||
"example": 1048576,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"onlineCount": {
|
||||
@@ -2110,10 +2140,12 @@
|
||||
},
|
||||
"updatedAt": {
|
||||
"example": 1700000000,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"uptimeSecs": {
|
||||
"example": 86400,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"xrayError": {
|
||||
@@ -2175,6 +2207,7 @@
|
||||
"description": "OutboundTraffics tracks traffic statistics for Xray outbound connections.",
|
||||
"properties": {
|
||||
"down": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"id": {
|
||||
@@ -2184,9 +2217,11 @@
|
||||
"type": "string"
|
||||
},
|
||||
"total": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"up": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
@@ -2208,6 +2243,7 @@
|
||||
},
|
||||
"finishedAt": {
|
||||
"example": 1735689612,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"runId": {
|
||||
@@ -2254,6 +2290,7 @@
|
||||
},
|
||||
"uptimeSecs": {
|
||||
"example": 86400,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"xrayError": {
|
||||
@@ -4063,7 +4100,7 @@
|
||||
"tags": [
|
||||
"Server"
|
||||
],
|
||||
"summary": "Stream the SQLite database file as an attachment. Use as a manual backup.",
|
||||
"summary": "Stream a full database backup as an attachment: the SQLite .db file on SQLite panels, or a pg_dump custom-format archive (.dump) on PostgreSQL panels. Use as a manual backup.",
|
||||
"operationId": "get_panel_api_server_getDb",
|
||||
"responses": {
|
||||
"200": {
|
||||
@@ -4807,7 +4844,7 @@
|
||||
"tags": [
|
||||
"Server"
|
||||
],
|
||||
"summary": "Restore the panel DB from an uploaded SQLite file (multipart form, field name \"db\"). The panel restarts after restore. Destructive.",
|
||||
"summary": "Restore the panel DB from an uploaded backup (multipart form, field name \"db\"). SQLite panels accept a SQLite database (.db) or a SQLite migration dump (.dump); PostgreSQL panels accept a pg_dump archive (.dump), a SQLite database (.db), or a SQLite migration dump. The panel restarts after restore. Destructive.",
|
||||
"operationId": "post_panel_api_server_importDB",
|
||||
"responses": {
|
||||
"200": {
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { Button, Input, Modal, message } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Input, Modal, Tabs, message } from 'antd';
|
||||
import { CopyOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import JsonEditor from '@/components/form/JsonEditor';
|
||||
import { ClipboardManager, FileManager } from '@/utils';
|
||||
|
||||
export interface TextModalTab {
|
||||
key: string;
|
||||
label: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface TextModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -12,13 +19,23 @@ interface TextModalProps {
|
||||
content: string;
|
||||
fileName?: string;
|
||||
json?: boolean;
|
||||
tabs?: TextModalTab[];
|
||||
}
|
||||
|
||||
export default function TextModal({ open, onClose, title, content, fileName = '', json = false }: TextModalProps) {
|
||||
export default function TextModal({ open, onClose, title, content, fileName = '', json = false, tabs }: TextModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const [activeKey, setActiveKey] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (open && tabs && tabs.length > 0) setActiveKey(tabs[0].key);
|
||||
}, [open, tabs]);
|
||||
|
||||
const activeTab = tabs?.find((tab) => tab.key === activeKey) ?? tabs?.[0];
|
||||
const activeContent = activeTab ? activeTab.content : content;
|
||||
|
||||
async function copy() {
|
||||
const ok = await ClipboardManager.copyText(content || '');
|
||||
const ok = await ClipboardManager.copyText(activeContent || '');
|
||||
if (ok) {
|
||||
messageApi.success(t('copied'));
|
||||
onClose();
|
||||
@@ -27,7 +44,7 @@ export default function TextModal({ open, onClose, title, content, fileName = ''
|
||||
|
||||
function download() {
|
||||
if (!fileName) return;
|
||||
FileManager.downloadTextFile(content, fileName);
|
||||
FileManager.downloadTextFile(activeContent, fileName);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -47,11 +64,18 @@ export default function TextModal({ open, onClose, title, content, fileName = ''
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{tabs && tabs.length > 0 && (
|
||||
<Tabs
|
||||
activeKey={activeTab?.key}
|
||||
onChange={setActiveKey}
|
||||
items={tabs.map((tab) => ({ key: tab.key, label: tab.label }))}
|
||||
/>
|
||||
)}
|
||||
{json ? (
|
||||
<JsonEditor value={content} readOnly minHeight="240px" maxHeight="60vh" />
|
||||
<JsonEditor value={activeContent} readOnly minHeight="240px" maxHeight="60vh" />
|
||||
) : (
|
||||
<Input.TextArea
|
||||
value={content}
|
||||
value={activeContent}
|
||||
readOnly
|
||||
autoSize={{ minRows: 10, maxRows: 20 }}
|
||||
style={{
|
||||
|
||||
@@ -855,6 +855,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"ApiToken": {
|
||||
"properties": {
|
||||
"createdAt": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"enabled": {
|
||||
@@ -884,6 +885,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"properties": {
|
||||
"createdAt": {
|
||||
"example": 1736000000,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"enabled": {
|
||||
@@ -934,6 +936,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
},
|
||||
"created_at": {
|
||||
"description": "Creation timestamp",
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"email": {
|
||||
@@ -946,6 +949,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
},
|
||||
"expiryTime": {
|
||||
"description": "Expiration timestamp",
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"flow": {
|
||||
@@ -1007,14 +1011,17 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
},
|
||||
"tgId": {
|
||||
"description": "Telegram user ID for notifications",
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"totalGB": {
|
||||
"description": "Total traffic limit in GB",
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"updated_at": {
|
||||
"description": "Last update timestamp",
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
@@ -1038,6 +1045,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"type": "integer"
|
||||
},
|
||||
"createdAt": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"flowOverride": {
|
||||
@@ -1070,6 +1078,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"type": "string"
|
||||
},
|
||||
"createdAt": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"email": {
|
||||
@@ -1079,6 +1088,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"type": "boolean"
|
||||
},
|
||||
"expiryTime": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"flow": {
|
||||
@@ -1122,12 +1132,15 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"type": "string"
|
||||
},
|
||||
"tgId": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"totalGB": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"updatedAt": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"uuid": {
|
||||
@@ -1180,6 +1193,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"properties": {
|
||||
"down": {
|
||||
"example": 2097152,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"email": {
|
||||
@@ -1192,6 +1206,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
},
|
||||
"expiryTime": {
|
||||
"example": 1735689600000,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"id": {
|
||||
@@ -1204,6 +1219,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
},
|
||||
"lastOnline": {
|
||||
"example": 1735680000000,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"reset": {
|
||||
@@ -1216,10 +1232,12 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
},
|
||||
"total": {
|
||||
"example": 10737418240,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"up": {
|
||||
"example": 1048576,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"uuid": {
|
||||
@@ -1290,6 +1308,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"type": "array"
|
||||
},
|
||||
"createdAt": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"echConfigList": {
|
||||
@@ -1405,6 +1424,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"type": "array"
|
||||
},
|
||||
"updatedAt": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"verifyPeerCertByName": {
|
||||
@@ -1634,6 +1654,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
},
|
||||
"down": {
|
||||
"description": "Download traffic in bytes",
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"enable": {
|
||||
@@ -1643,6 +1664,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
},
|
||||
"expiryTime": {
|
||||
"description": "Expiration timestamp",
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"fallbackParent": {
|
||||
@@ -1661,6 +1683,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
},
|
||||
"lastTrafficResetTime": {
|
||||
"description": "Last traffic reset timestamp",
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"listen": {
|
||||
@@ -1729,6 +1752,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
},
|
||||
"total": {
|
||||
"description": "Total traffic limit in bytes",
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"trafficReset": {
|
||||
@@ -1744,6 +1768,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
},
|
||||
"up": {
|
||||
"description": "Upload traffic in bytes",
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
@@ -1955,6 +1980,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"type": "boolean"
|
||||
},
|
||||
"configDirtyAt": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"cpuPct": {
|
||||
@@ -1963,6 +1989,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
},
|
||||
"createdAt": {
|
||||
"example": 1700000000,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"depletedCount": {
|
||||
@@ -2008,6 +2035,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"lastHeartbeat": {
|
||||
"description": "unix seconds, 0 = never",
|
||||
"example": 1700000000,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"latencyMs": {
|
||||
@@ -2024,10 +2052,12 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
},
|
||||
"netDown": {
|
||||
"example": 2097152,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"netUp": {
|
||||
"example": 1048576,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"onlineCount": {
|
||||
@@ -2084,10 +2114,12 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
},
|
||||
"updatedAt": {
|
||||
"example": 1700000000,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"uptimeSecs": {
|
||||
"example": 86400,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"xrayError": {
|
||||
@@ -2149,6 +2181,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"description": "OutboundTraffics tracks traffic statistics for Xray outbound connections.",
|
||||
"properties": {
|
||||
"down": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"id": {
|
||||
@@ -2158,9 +2191,11 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"type": "string"
|
||||
},
|
||||
"total": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"up": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
@@ -2182,6 +2217,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
},
|
||||
"finishedAt": {
|
||||
"example": 1735689612,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"runId": {
|
||||
@@ -2228,6 +2264,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
},
|
||||
"uptimeSecs": {
|
||||
"example": 86400,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"xrayError": {
|
||||
|
||||
@@ -81,6 +81,8 @@ function defaultTcpMaskSettings(type: string): Record<string, unknown> {
|
||||
};
|
||||
case 'header-custom':
|
||||
return { clients: [], servers: [] };
|
||||
case 'xmc':
|
||||
return { hostname: '', usernames: [], password: RandomUtil.randomLowerAndNum(16) };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
@@ -294,6 +296,7 @@ function TcpMaskItem({
|
||||
{ value: 'fragment', label: 'Fragment' },
|
||||
{ value: 'header-custom', label: 'Header Custom' },
|
||||
{ value: 'sudoku', label: 'Sudoku' },
|
||||
{ value: 'xmc', label: 'XMC (Minecraft)' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -371,6 +374,41 @@ function TcpMaskItem({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (type === 'xmc') {
|
||||
return (
|
||||
<>
|
||||
<Form.Item label="Hostname" name={[fieldName, 'settings', 'hostname']}>
|
||||
<Input placeholder="Server address mimicked in the handshake" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Usernames"
|
||||
name={[fieldName, 'settings', 'usernames']}
|
||||
extra="Player names offered to probes; core defaults to Dream when empty."
|
||||
>
|
||||
<Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} />
|
||||
</Form.Item>
|
||||
<Form.Item label="Password" required>
|
||||
<Space.Compact block>
|
||||
<Form.Item
|
||||
name={[fieldName, 'settings', 'password']}
|
||||
noStyle
|
||||
rules={[{ required: true, message: 'Password is required' }]}
|
||||
>
|
||||
<Input placeholder="Obfuscation password" style={{ width: 'calc(100% - 32px)' }} />
|
||||
</Form.Item>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
aria-label={t('regenerate')}
|
||||
onClick={() => form.setFieldValue(
|
||||
[...absolutePath, 'settings', 'password'],
|
||||
RandomUtil.randomLowerAndNum(16),
|
||||
)}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
</Form.Item>
|
||||
|
||||
@@ -124,6 +124,10 @@ const NETWORK_SETTINGS_KEY: Record<string, string> = {
|
||||
};
|
||||
|
||||
function healStreamNetworkKey(stream: Record<string, unknown>): void {
|
||||
if (typeof stream.method === 'string' && stream.method !== '') {
|
||||
stream.network = stream.method;
|
||||
}
|
||||
delete stream.method;
|
||||
const network = typeof stream.network === 'string' ? stream.network : '';
|
||||
const key = NETWORK_SETTINGS_KEY[network];
|
||||
if (!key) return;
|
||||
|
||||
@@ -107,7 +107,7 @@ function vmessFromWire(raw: Raw): VmessOutboundFormSettings {
|
||||
id: asString(u.id),
|
||||
security: ((): VmessOutboundFormSettings['security'] => {
|
||||
const s = asString(u.security);
|
||||
const allowed = ['aes-128-gcm', 'chacha20-poly1305', 'auto', 'none', 'zero'];
|
||||
const allowed = ['aes-128-gcm', 'chacha20-poly1305', 'auto'];
|
||||
return (allowed.includes(s) ? s : 'auto') as VmessOutboundFormSettings['security'];
|
||||
})(),
|
||||
};
|
||||
@@ -387,10 +387,14 @@ export interface RawOutboundRow {
|
||||
mux?: unknown;
|
||||
}
|
||||
|
||||
export const XMUX_DEFAULTS = XHttpXmuxSchema.parse({});
|
||||
const XMUX_DEFAULTS = XHttpXmuxSchema.parse({});
|
||||
|
||||
function hydrateStreamForm(stream: Raw): OutboundStreamFormValues {
|
||||
const next = { ...stream };
|
||||
if (typeof next.method === 'string' && next.method !== '') {
|
||||
next.network = next.method;
|
||||
}
|
||||
delete next.method;
|
||||
const xh = next.xhttpSettings;
|
||||
if (xh && typeof xh === 'object' && !Array.isArray(xh)) {
|
||||
const xhttp = { ...(xh as Raw) };
|
||||
|
||||
@@ -360,6 +360,8 @@ export function parseVmessLink(link: string): Raw | null {
|
||||
}
|
||||
|
||||
const port = Number(json.port) || 443;
|
||||
const rawScy = (json.scy as string) || 'auto';
|
||||
const userSecurity = rawScy === 'none' || rawScy === 'zero' ? 'auto' : rawScy;
|
||||
return {
|
||||
protocol: 'vmess',
|
||||
tag: typeof json.ps === 'string' ? json.ps : '',
|
||||
@@ -367,7 +369,7 @@ export function parseVmessLink(link: string): Raw | null {
|
||||
vnext: [{
|
||||
address: json.add ?? '',
|
||||
port,
|
||||
users: [{ id: json.id ?? '', security: (json.scy as string) || 'auto' }],
|
||||
users: [{ id: json.id ?? '', security: userSecurity }],
|
||||
}],
|
||||
},
|
||||
streamSettings: stream,
|
||||
|
||||
@@ -46,7 +46,7 @@ function hasMeaningfulHeaders(headers: unknown): boolean {
|
||||
// Upper bound of an xray-core Int32Range value: "16-32" -> 32, "4" -> 4,
|
||||
// 4 -> 4, "" / null -> 0. xmux fields are ranges, and xray-core keys its
|
||||
// mutual-exclusivity check on the `.To` (upper) side.
|
||||
function int32RangeUpper(v: unknown): number {
|
||||
export function int32RangeUpper(v: unknown): number {
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? v : 0;
|
||||
if (typeof v !== 'string') return 0;
|
||||
const trimmed = v.trim();
|
||||
@@ -57,13 +57,9 @@ function int32RangeUpper(v: unknown): number {
|
||||
}
|
||||
|
||||
// xray-core's XmuxConfig rejects a config that sets BOTH maxConnections
|
||||
// and maxConcurrency ("maxConnections cannot be specified together with
|
||||
// maxConcurrency"). The panel pre-fills maxConcurrency ("16-32") whenever
|
||||
// XMUX is enabled, so any explicit maxConnections would otherwise always
|
||||
// collide and make xray refuse the config. maxConnections defaults to 0
|
||||
// (off), so a positive value is an explicit opt-in to connection-pool
|
||||
// mode — honor it and drop the leftover default maxConcurrency, matching
|
||||
// core's "one strategy at a time" semantics.
|
||||
// and maxConcurrency. A positive maxConnections is an explicit opt-in to
|
||||
// connection-pool mode — honor it and drop the leftover maxConcurrency
|
||||
// default that load-time hydration backfills onto older saved configs.
|
||||
function resolveXmuxExclusivity(xmux: Record<string, unknown>): Record<string, unknown> {
|
||||
if (int32RangeUpper(xmux.maxConnections) > 0 && int32RangeUpper(xmux.maxConcurrency) > 0) {
|
||||
const out = { ...xmux };
|
||||
|
||||
@@ -338,7 +338,7 @@ export const sections: readonly Section[] = [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/getDb',
|
||||
summary: 'Stream the SQLite database file as an attachment. Use as a manual backup.',
|
||||
summary: 'Stream a full database backup as an attachment: the SQLite .db file on SQLite panels, or a pg_dump custom-format archive (.dump) on PostgreSQL panels. Use as a manual backup.',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
@@ -468,9 +468,9 @@ export const sections: readonly Section[] = [
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/server/importDB',
|
||||
summary: 'Restore the panel DB from an uploaded SQLite file (multipart form, field name "db"). The panel restarts after restore. Destructive.',
|
||||
summary: 'Restore the panel DB from an uploaded backup (multipart form, field name "db"). SQLite panels accept a SQLite database (.db) or a SQLite migration dump (.dump); PostgreSQL panels accept a pg_dump archive (.dump), a SQLite database (.db), or a SQLite migration dump. The panel restarts after restore. Destructive.',
|
||||
params: [
|
||||
{ name: 'db', in: 'body (multipart)', type: 'file', desc: 'SQLite database file to upload.' },
|
||||
{ name: 'db', in: 'body (multipart)', type: 'file', desc: 'Database backup or migration file to upload.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { InboundOption } from '@/hooks/useClients';
|
||||
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||
import type { BulkAttachResult } from '@/schemas/client';
|
||||
|
||||
const MULTI_USER_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'hysteria', 'shadowsocks']);
|
||||
const MULTI_USER_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'hysteria', 'shadowsocks', 'wireguard', 'mtproto']);
|
||||
|
||||
interface BulkAttachInboundsModalProps {
|
||||
open: boolean;
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { InboundOption } from '@/hooks/useClients';
|
||||
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||
import type { BulkDetachResult } from '@/schemas/client';
|
||||
|
||||
const MULTI_USER_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'hysteria', 'shadowsocks']);
|
||||
const MULTI_USER_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'hysteria', 'shadowsocks', 'wireguard', 'mtproto']);
|
||||
|
||||
interface BulkDetachInboundsModalProps {
|
||||
open: boolean;
|
||||
|
||||
@@ -36,7 +36,7 @@ import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2
|
||||
import { ClientFormSchema, ClientCreateFormSchema, type ClientFormValues } from '@/schemas/client';
|
||||
|
||||
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
||||
const VMESS_SECURITY_OPTIONS = ['auto', 'aes-128-gcm', 'chacha20-poly1305', 'none', 'zero'] as const;
|
||||
const VMESS_SECURITY_OPTIONS = ['auto', 'aes-128-gcm', 'chacha20-poly1305'] as const;
|
||||
|
||||
const MULTI_CLIENT_PROTOCOLS = new Set([
|
||||
'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'wireguard', 'mtproto',
|
||||
@@ -217,7 +217,9 @@ export default function ClientFormModal({
|
||||
password: client.password || '',
|
||||
auth: client.auth || '',
|
||||
flow: client.flow || '',
|
||||
security: client.security || 'auto',
|
||||
security: !client.security || client.security === 'none' || client.security === 'zero'
|
||||
? 'auto'
|
||||
: client.security,
|
||||
reverseTag: client.reverse?.tag || '',
|
||||
totalGB: bytesToGB(client.totalGB || 0),
|
||||
reset: Number(client.reset) || 0,
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
|
||||
import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
|
||||
import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults';
|
||||
import { genInboundLinks, preferPublicHost } from '@/lib/xray/inbound-link';
|
||||
import { genInboundLinks, genWireguardLinks, preferPublicHost } from '@/lib/xray/inbound-link';
|
||||
import { inboundFromDb } from '@/lib/xray/inbound-from-db';
|
||||
import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
@@ -33,6 +33,7 @@ import { useWebSocket } from '@/hooks/useWebSocket';
|
||||
import { useNodesQuery } from '@/api/queries/useNodesQuery';
|
||||
import AppSidebar from '@/layouts/AppSidebar';
|
||||
const TextModal = lazy(() => import('@/components/feedback/TextModal'));
|
||||
import type { TextModalTab } from '@/components/feedback/TextModal';
|
||||
const PromptModal = lazy(() => import('@/components/feedback/PromptModal'));
|
||||
|
||||
import { useInbounds } from './useInbounds';
|
||||
@@ -148,6 +149,7 @@ export default function InboundsPage() {
|
||||
const [textContent, setTextContent] = useState('');
|
||||
const [textFileName, setTextFileName] = useState('');
|
||||
const [textJson, setTextJson] = useState(false);
|
||||
const [textTabs, setTextTabs] = useState<TextModalTab[] | undefined>(undefined);
|
||||
|
||||
const [promptOpen, setPromptOpen] = useState(false);
|
||||
const [promptTitle, setPromptTitle] = useState('');
|
||||
@@ -166,11 +168,12 @@ export default function InboundsPage() {
|
||||
const infoNodeAddress = useMemo(() => hostOverrideFor(infoDbInbound), [infoDbInbound, hostOverrideFor]);
|
||||
const qrNodeAddress = useMemo(() => hostOverrideFor(qrDbInbound), [qrDbInbound, hostOverrideFor]);
|
||||
|
||||
const openText = useCallback((opts: { title: string; content: string; fileName?: string; json?: boolean }) => {
|
||||
const openText = useCallback((opts: { title: string; content: string; fileName?: string; json?: boolean; tabs?: TextModalTab[] }) => {
|
||||
setTextTitle(opts.title);
|
||||
setTextContent(opts.content);
|
||||
setTextFileName(opts.fileName || '');
|
||||
setTextJson(opts.json || false);
|
||||
setTextTabs(opts.tabs);
|
||||
setTextOpen(true);
|
||||
}, []);
|
||||
|
||||
@@ -259,15 +262,24 @@ export default function InboundsPage() {
|
||||
|
||||
const exportInboundLinks = useCallback((dbInbound: DBInbound) => {
|
||||
const projected = checkFallback(dbInbound);
|
||||
const genInput = {
|
||||
inbound: inboundFromDb(projected),
|
||||
remark: projected.remark,
|
||||
hostOverride: hostOverrideFor(dbInbound),
|
||||
fallbackHostname: preferPublicHost(window.location.hostname, subSettings.publicHost),
|
||||
};
|
||||
const content = genInboundLinks(genInput);
|
||||
const tabs: TextModalTab[] | undefined = projected.isWireguard
|
||||
? [
|
||||
{ key: 'config', label: t('pages.clients.config'), content },
|
||||
{ key: 'links', label: t('pages.clients.tabLinks'), content: genWireguardLinks(genInput) },
|
||||
]
|
||||
: undefined;
|
||||
openText({
|
||||
title: t('pages.inbounds.exportLinksTitle'),
|
||||
content: genInboundLinks({
|
||||
inbound: inboundFromDb(projected),
|
||||
remark: projected.remark,
|
||||
hostOverride: hostOverrideFor(dbInbound),
|
||||
fallbackHostname: preferPublicHost(window.location.hostname, subSettings.publicHost),
|
||||
}),
|
||||
content,
|
||||
fileName: projected.remark || 'inbound',
|
||||
tabs,
|
||||
});
|
||||
}, [checkFallback, hostOverrideFor, subSettings.publicHost, openText, t]);
|
||||
|
||||
@@ -706,6 +718,7 @@ export default function InboundsPage() {
|
||||
content={textContent}
|
||||
fileName={textFileName}
|
||||
json={textJson}
|
||||
tabs={textTabs}
|
||||
/>
|
||||
</LazyMount>
|
||||
<LazyMount when={promptOpen}>
|
||||
|
||||
@@ -128,7 +128,7 @@ export default function RealityForm({
|
||||
name={['streamSettings', 'realitySettings', 'minClientVer']}
|
||||
label={t('pages.inbounds.form.minClientVer')}
|
||||
>
|
||||
<Input placeholder="25.9.11" />
|
||||
<Input placeholder="26.3.27" />
|
||||
</FormField>
|
||||
<FormField
|
||||
name={['streamSettings', 'realitySettings', 'maxClientVer']}
|
||||
|
||||
@@ -4,10 +4,9 @@ import { useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
import { HeaderMapEditor } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { XHTTP_SESSION_ID_TABLES, XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
|
||||
import { XHTTP_SESSION_ID_TABLES, XMUX_FRESH_DEFAULTS } from '@/schemas/protocols/stream/xhttp';
|
||||
import { validateSessionIDLength, validateSessionIDTable } from '@/lib/xray/xhttp-session-id';
|
||||
|
||||
const XMUX_DEFAULTS = XHttpXmuxSchema.parse({});
|
||||
import { int32RangeUpper } from '@/lib/xray/stream-wire-normalize';
|
||||
|
||||
function antdValidatorToRhf(fn: (rule: unknown, value: unknown) => Promise<void>) {
|
||||
return async (value: unknown): Promise<true | string> => {
|
||||
@@ -36,7 +35,21 @@ export default function XhttpForm() {
|
||||
const existing = getValues('streamSettings.xhttpSettings.xmux');
|
||||
const hasValues = existing && typeof existing === 'object' && Object.keys(existing).length > 0;
|
||||
if (hasValues) return;
|
||||
setValue('streamSettings.xhttpSettings.xmux', { ...XMUX_DEFAULTS });
|
||||
setValue('streamSettings.xhttpSettings.xmux', { ...XMUX_FRESH_DEFAULTS });
|
||||
}
|
||||
|
||||
function onXmuxMaxConcurrencyChange(value: unknown) {
|
||||
if (int32RangeUpper(value) <= 0) return;
|
||||
if (int32RangeUpper(getValues('streamSettings.xhttpSettings.xmux.maxConnections')) > 0) {
|
||||
setValue('streamSettings.xhttpSettings.xmux.maxConnections', 0);
|
||||
}
|
||||
}
|
||||
|
||||
function onXmuxMaxConnectionsChange(value: unknown) {
|
||||
if (int32RangeUpper(value) <= 0) return;
|
||||
if (int32RangeUpper(getValues('streamSettings.xhttpSettings.xmux.maxConcurrency')) > 0) {
|
||||
setValue('streamSettings.xhttpSettings.xmux.maxConcurrency', '');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -295,12 +308,14 @@ export default function XhttpForm() {
|
||||
<FormField
|
||||
label={t('pages.xray.outboundForm.maxConcurrency')}
|
||||
name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConcurrency']}
|
||||
onAfterChange={onXmuxMaxConcurrencyChange}
|
||||
>
|
||||
<Input placeholder="16-32" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.xray.outboundForm.maxConnections')}
|
||||
name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConnections']}
|
||||
onAfterChange={onXmuxMaxConnectionsChange}
|
||||
>
|
||||
<Input placeholder="0" />
|
||||
</FormField>
|
||||
|
||||
@@ -43,7 +43,7 @@ export function buildRowActionsMenu({ record, subEnable, t, isMobile, hasClients
|
||||
label: `${t('pages.inbounds.export')} — ${t('pages.settings.subSettings')}`,
|
||||
});
|
||||
}
|
||||
} else if (!record.isWireguard) {
|
||||
} else {
|
||||
items.push({ key: 'showInfo', icon: <InfoCircleOutlined />, label: t('pages.inbounds.inboundInfo') });
|
||||
}
|
||||
items.push({ key: 'clipboard', icon: <CopyOutlined />, label: t('pages.inbounds.exportInbound') });
|
||||
|
||||
@@ -73,6 +73,7 @@ export function isInboundMultiUser(record: { protocol: string; settings: unknown
|
||||
case 'trojan':
|
||||
case 'hysteria':
|
||||
case 'mtproto':
|
||||
case 'wireguard':
|
||||
return true;
|
||||
case 'shadowsocks':
|
||||
return isSSMultiUser({ protocol: 'shadowsocks', settings: readSettings(record.settings) });
|
||||
|
||||
@@ -60,6 +60,8 @@ const TRACKED_PROTOCOLS: readonly string[] = [
|
||||
Protocols.TROJAN,
|
||||
Protocols.SHADOWSOCKS,
|
||||
Protocols.HYSTERIA,
|
||||
Protocols.WIREGUARD,
|
||||
Protocols.MTPROTO,
|
||||
];
|
||||
|
||||
async function fetchSlimInbounds(): Promise<unknown[]> {
|
||||
|
||||
@@ -32,7 +32,7 @@ export default function BackupModal({ open, basePath: _basePath, onClose, onBusy
|
||||
function importDb() {
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = isPostgres ? '.dump' : '.db';
|
||||
fileInput.accept = '.dump,.db';
|
||||
fileInput.addEventListener('change', async (e) => {
|
||||
const dbFile = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!dbFile) return;
|
||||
@@ -86,15 +86,15 @@ export default function BackupModal({ open, basePath: _basePath, onClose, onBusy
|
||||
<Button type="primary" aria-label={t('pages.index.exportDatabase')} onClick={exportDb} icon={<DownloadOutlined />} />
|
||||
</div>
|
||||
|
||||
<div className="backup-item">
|
||||
<div className="backup-meta">
|
||||
<div className="backup-title">{t('pages.index.migrationDownload')}</div>
|
||||
<div className="backup-description">
|
||||
{isPostgres ? t('pages.index.migrationDownloadPgDesc') : t('pages.index.migrationDownloadDesc')}
|
||||
{isPostgres && (
|
||||
<div className="backup-item">
|
||||
<div className="backup-meta">
|
||||
<div className="backup-title">{t('pages.index.migrationDownload')}</div>
|
||||
<div className="backup-description">{t('pages.index.migrationDownloadPgDesc')}</div>
|
||||
</div>
|
||||
<Button type="primary" aria-label={t('pages.index.migrationDownload')} onClick={exportMigration} icon={<DownloadOutlined />} />
|
||||
</div>
|
||||
<Button type="primary" aria-label={t('pages.index.migrationDownload')} onClick={exportMigration} icon={<DownloadOutlined />} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="backup-item">
|
||||
<div className="backup-meta">
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { CloudOutlined } from '@ant-design/icons';
|
||||
|
||||
interface CountryPillProps {
|
||||
flag: string;
|
||||
name: string;
|
||||
warp?: string;
|
||||
}
|
||||
|
||||
export default function CountryPill({ flag, name, warp }: CountryPillProps) {
|
||||
const isWarp = !!warp && warp.toLowerCase() !== 'off';
|
||||
return (
|
||||
<span className={isWarp ? 'country-pill warp-on' : 'country-pill'}>
|
||||
{isWarp && <CloudOutlined className="warp-cloud-icon" />}
|
||||
{flag && <span>{flag}</span>}
|
||||
<span>{name}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Dropdown, Tag, Tooltip } from 'antd';
|
||||
import {
|
||||
@@ -9,15 +10,21 @@ import {
|
||||
ThunderboltOutlined,
|
||||
LoadingOutlined,
|
||||
ExportOutlined,
|
||||
EyeInvisibleOutlined,
|
||||
EyeOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { SizeFormatter } from '@/utils';
|
||||
import { activateOnKey } from '@/utils/a11y';
|
||||
import { OutboundProtocols as Protocols } from '@/schemas/primitives';
|
||||
import type { OutboundTestMode, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||
|
||||
import type { OutboundRow } from './outbounds-tab-types';
|
||||
import CountryPill from './CountryPill';
|
||||
import TestResultPopover from './TestResultPopover';
|
||||
import {
|
||||
countryFlag,
|
||||
countryName,
|
||||
isTesting,
|
||||
isUntestable,
|
||||
outboundAddresses,
|
||||
@@ -49,7 +56,55 @@ export default function OutboundCardList({
|
||||
confirmDelete,
|
||||
onTest,
|
||||
}: OutboundCardListProps) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const [showEgressIp, setShowEgressIp] = useState<Record<string, boolean>>({});
|
||||
|
||||
const setCardEgressVisible = (key: string, visible: boolean) => {
|
||||
setShowEgressIp((prev) => ({ ...prev, [key]: visible }));
|
||||
};
|
||||
|
||||
const renderEgress = (index: number, rowKey: string) => {
|
||||
const result = testResult(outboundTestStates, index);
|
||||
const egress = result?.egress;
|
||||
const isEgressVisible = !!showEgressIp[rowKey];
|
||||
const flag = countryFlag(egress?.country);
|
||||
const name = countryName(egress?.country, i18n.language);
|
||||
const addresses = [
|
||||
egress?.ipv4 ? { label: 'v4', value: egress.ipv4 } : null,
|
||||
egress?.ipv6 ? { label: 'v6', value: egress.ipv6 } : null,
|
||||
].filter((item): item is { label: string; value: string } => Boolean(item));
|
||||
|
||||
if (!egress || (addresses.length === 0 && !egress.country)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card-egress">
|
||||
<div className="card-egress-row">
|
||||
<span>{t('pages.xray.outbound.egress')}:</span>
|
||||
<Tooltip title={t('pages.index.toggleIpVisibility')}>
|
||||
{isEgressVisible ? (
|
||||
<EyeOutlined className="ip-toggle-icon" role="button" tabIndex={0} aria-label={t('pages.index.toggleIpVisibility')} onClick={() => setCardEgressVisible(rowKey, false)} onKeyDown={activateOnKey(() => setCardEgressVisible(rowKey, false))} />
|
||||
) : (
|
||||
<EyeInvisibleOutlined className="ip-toggle-icon" role="button" tabIndex={0} aria-label={t('pages.index.toggleIpVisibility')} onClick={() => setCardEgressVisible(rowKey, true)} onKeyDown={activateOnKey(() => setCardEgressVisible(rowKey, true))} />
|
||||
)}
|
||||
</Tooltip>
|
||||
{egress.country && (
|
||||
<CountryPill flag={flag} name={name || egress.country} warp={egress.warp} />
|
||||
)}
|
||||
</div>
|
||||
{addresses.map((addr) => (
|
||||
<Tooltip key={addr.label} title={addr.value}>
|
||||
<div className="card-egress-row">
|
||||
<span className="egress-family">{addr.label}:</span>
|
||||
<span className={isEgressVisible ? 'address-visible egress-ip' : 'address-hidden egress-ip'}>{addr.value}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="card-empty">
|
||||
@@ -101,6 +156,7 @@ export default function OutboundCardList({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{renderEgress(index, String(record.key))}
|
||||
<div className="card-foot">
|
||||
<span className="traffic-up">↑ {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).up)}</span>
|
||||
<span className="traffic-sep" />
|
||||
|
||||
@@ -17,11 +17,11 @@ import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
||||
import { JsonEditor } from '@/components/form';
|
||||
import { Wireguard } from '@/utils';
|
||||
import {
|
||||
XMUX_DEFAULTS,
|
||||
formValuesToWirePayload,
|
||||
rawOutboundToFormValues,
|
||||
} from '@/lib/xray/outbound-form-adapter';
|
||||
import { parseOutboundLink } from '@/lib/xray/outbound-link-parser';
|
||||
import { XMUX_FRESH_DEFAULTS } from '@/schemas/protocols/stream/xhttp';
|
||||
import {
|
||||
OutboundFormBaseSchema,
|
||||
type OutboundFormValues,
|
||||
@@ -255,7 +255,7 @@ export default function OutboundFormModal({
|
||||
const existing = methods.getValues('streamSettings.xhttpSettings.xmux');
|
||||
const hasValues = existing && typeof existing === 'object' && Object.keys(existing).length > 0;
|
||||
if (hasValues) return;
|
||||
methods.setValue('streamSettings.xhttpSettings.xmux', { ...XMUX_DEFAULTS });
|
||||
methods.setValue('streamSettings.xhttpSettings.xmux', { ...XMUX_FRESH_DEFAULTS });
|
||||
}
|
||||
|
||||
const duplicateTag = useMemo(() => {
|
||||
|
||||
@@ -75,6 +75,81 @@
|
||||
background: var(--ant-color-fill-tertiary);
|
||||
}
|
||||
|
||||
.egress-header {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.egress-ip {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.egress-stack {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.egress-address {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.egress-family {
|
||||
color: var(--ant-color-text-secondary);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.country-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--ant-color-border);
|
||||
border-radius: 4px;
|
||||
padding: 1px 6px;
|
||||
line-height: 20px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.country-pill.warp-on {
|
||||
border-color: #f48120;
|
||||
}
|
||||
|
||||
.warp-cloud-icon {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
color: #f48120;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.warp-cloud-icon svg {
|
||||
fill: #f48120;
|
||||
}
|
||||
|
||||
.ip-toggle-icon {
|
||||
cursor: pointer;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.ip-toggle-icon:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.address-hidden {
|
||||
filter: blur(5px);
|
||||
transition: filter 0.2s ease;
|
||||
}
|
||||
|
||||
.address-visible {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.action-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -110,6 +185,23 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card-egress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 3px;
|
||||
font-size: 12px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.card-egress-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.card-test {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
|
||||
@@ -61,6 +61,22 @@ export function trafficFor(outboundsTraffic: OutboundTrafficRow[], o: OutboundRo
|
||||
return { up: tr?.up || 0, down: tr?.down || 0 };
|
||||
}
|
||||
|
||||
export function countryFlag(country?: string): string {
|
||||
const code = (country || '').trim().toUpperCase();
|
||||
if (!/^[A-Z]{2}$/.test(code)) return '';
|
||||
return String.fromCodePoint(...[...code].map((ch) => 0x1f1e6 + ch.charCodeAt(0) - 65));
|
||||
}
|
||||
|
||||
export function countryName(country?: string, locale?: string): string {
|
||||
const code = (country || '').trim().toUpperCase();
|
||||
if (!/^[A-Z]{2}$/.test(code)) return '';
|
||||
try {
|
||||
return new Intl.DisplayNames(locale ? [locale] : undefined, { type: 'region' }).of(code) || code;
|
||||
} catch {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
export function isTesting<K extends string | number>(states: Record<K, OutboundTestState>, idx: K): boolean {
|
||||
return !!states?.[idx]?.testing;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useFormContext, useWatch } from 'react-hook-form';
|
||||
import { HeaderMapEditor } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { validateSessionIDLength, validateSessionIDTable } from '@/lib/xray/xhttp-session-id';
|
||||
import { int32RangeUpper } from '@/lib/xray/stream-wire-normalize';
|
||||
import { XHTTP_SESSION_ID_TABLES } from '@/schemas/protocols/stream/xhttp';
|
||||
|
||||
import { MODE_OPTIONS } from '../outbound-form-constants';
|
||||
@@ -28,7 +29,7 @@ const XH = 'streamSettings.xhttpSettings';
|
||||
|
||||
export default function XhttpForm({ onXmuxToggle }: XhttpFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const { control } = useFormContext();
|
||||
const { control, getValues, setValue } = useFormContext();
|
||||
const mode = useWatch({ control, name: `${XH}.mode` }) as string | undefined;
|
||||
const obfs = !!useWatch({ control, name: `${XH}.xPaddingObfsMode` });
|
||||
const sessionPlacement = useWatch({ control, name: `${XH}.sessionIDPlacement` }) as string | undefined;
|
||||
@@ -37,6 +38,20 @@ export default function XhttpForm({ onXmuxToggle }: XhttpFormProps) {
|
||||
const uplinkDataPlacement = useWatch({ control, name: `${XH}.uplinkDataPlacement` }) as string | undefined;
|
||||
const enableXmux = !!useWatch({ control, name: `${XH}.enableXmux` });
|
||||
|
||||
function onXmuxMaxConcurrencyChange(value: unknown) {
|
||||
if (int32RangeUpper(value) <= 0) return;
|
||||
if (int32RangeUpper(getValues(`${XH}.xmux.maxConnections`)) > 0) {
|
||||
setValue(`${XH}.xmux.maxConnections`, 0);
|
||||
}
|
||||
}
|
||||
|
||||
function onXmuxMaxConnectionsChange(value: unknown) {
|
||||
if (int32RangeUpper(value) <= 0) return;
|
||||
if (int32RangeUpper(getValues(`${XH}.xmux.maxConcurrency`)) > 0) {
|
||||
setValue(`${XH}.xmux.maxConcurrency`, '');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField label={t('host')} name={['streamSettings', 'xhttpSettings', 'host']}>
|
||||
@@ -283,12 +298,14 @@ export default function XhttpForm({ onXmuxToggle }: XhttpFormProps) {
|
||||
<FormField
|
||||
label={t('pages.xray.outboundForm.maxConcurrency')}
|
||||
name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConcurrency']}
|
||||
onAfterChange={onXmuxMaxConcurrencyChange}
|
||||
>
|
||||
<Input placeholder="16-32" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.xray.outboundForm.maxConnections')}
|
||||
name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConnections']}
|
||||
onAfterChange={onXmuxMaxConnectionsChange}
|
||||
>
|
||||
<Input placeholder="0" />
|
||||
</FormField>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Dropdown, Tag, Tooltip } from 'antd';
|
||||
import {
|
||||
@@ -11,17 +11,23 @@ import {
|
||||
LoadingOutlined,
|
||||
ArrowUpOutlined,
|
||||
ArrowDownOutlined,
|
||||
EyeInvisibleOutlined,
|
||||
EyeOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
import { SizeFormatter } from '@/utils';
|
||||
import { activateOnKey } from '@/utils/a11y';
|
||||
import { OutboundProtocols as Protocols } from '@/schemas/primitives';
|
||||
import type { OutboundTestMode, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||
|
||||
import type { OutboundRow } from './outbounds-tab-types';
|
||||
import CountryPill from './CountryPill';
|
||||
import TestResultPopover from './TestResultPopover';
|
||||
import {
|
||||
effectiveTestMode,
|
||||
countryFlag,
|
||||
countryName,
|
||||
isTesting,
|
||||
isUntestable,
|
||||
outboundAddresses,
|
||||
@@ -58,7 +64,8 @@ export function useOutboundColumns({
|
||||
onResetTraffic,
|
||||
onTest,
|
||||
}: OutboundColumnsParams): ColumnsType<OutboundRow> {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const [showEgressIp, setShowEgressIp] = useState(false);
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -135,6 +142,72 @@ export function useOutboundColumns({
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: (
|
||||
<span className="egress-header">
|
||||
{t('pages.xray.outbound.egress')}
|
||||
<Tooltip title={t('pages.index.toggleIpVisibility')}>
|
||||
{showEgressIp ? (
|
||||
<EyeOutlined className="ip-toggle-icon" role="button" tabIndex={0} aria-label={t('pages.index.toggleIpVisibility')} onClick={() => setShowEgressIp(false)} onKeyDown={activateOnKey(() => setShowEgressIp(false))} />
|
||||
) : (
|
||||
<EyeInvisibleOutlined className="ip-toggle-icon" role="button" tabIndex={0} aria-label={t('pages.index.toggleIpVisibility')} onClick={() => setShowEgressIp(true)} onKeyDown={activateOnKey(() => setShowEgressIp(true))} />
|
||||
)}
|
||||
</Tooltip>
|
||||
</span>
|
||||
),
|
||||
key: 'egress',
|
||||
align: 'left',
|
||||
width: 210,
|
||||
render: (_v, _record, index) => {
|
||||
const egress = testResult(outboundTestStates, index)?.egress;
|
||||
const addresses = [
|
||||
egress?.ipv4 ? { label: 'v4', value: egress.ipv4 } : null,
|
||||
egress?.ipv6 ? { label: 'v6', value: egress.ipv6 } : null,
|
||||
].filter((item): item is { label: string; value: string } => Boolean(item));
|
||||
if (addresses.length === 0) {
|
||||
return (
|
||||
<Tooltip title={t('pages.xray.outbound.egressHint')}>
|
||||
<span className="empty">—</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="egress-stack">
|
||||
{addresses.map((addr) => (
|
||||
<Tooltip key={addr.label} title={addr.value}>
|
||||
<span className="egress-address">
|
||||
<span className="egress-family">{addr.label}</span>
|
||||
<span className={showEgressIp ? 'address-visible egress-ip' : 'address-hidden egress-ip'}>{addr.value}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('pages.xray.outbound.country'),
|
||||
key: 'egressCountry',
|
||||
align: 'left',
|
||||
width: 160,
|
||||
render: (_v, _record, index) => {
|
||||
const egress = testResult(outboundTestStates, index)?.egress;
|
||||
if (!egress?.country) {
|
||||
return (
|
||||
<Tooltip title={t('pages.xray.outbound.egressHint')}>
|
||||
<span className="empty">—</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
const flag = countryFlag(egress.country);
|
||||
const name = countryName(egress.country, i18n.language);
|
||||
return (
|
||||
<Tooltip title={egress.warp ? `Cloudflare trace · WARP ${egress.warp}` : 'Cloudflare trace'}>
|
||||
<CountryPill flag={flag} name={name || egress.country} warp={egress.warp} />
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('pages.inbounds.traffic'),
|
||||
key: 'traffic',
|
||||
@@ -183,6 +256,6 @@ export function useOutboundColumns({
|
||||
},
|
||||
],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[t, testMode, rows, outboundTestStates, outboundsTraffic],
|
||||
[t, i18n.language, testMode, rows, outboundTestStates, outboundsTraffic, showEgressIp],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,8 +30,6 @@ export const USERS_SECURITY = Object.freeze({
|
||||
AES_128_GCM: 'aes-128-gcm',
|
||||
CHACHA20_POLY1305: 'chacha20-poly1305',
|
||||
AUTO: 'auto',
|
||||
NONE: 'none',
|
||||
ZERO: 'zero',
|
||||
});
|
||||
|
||||
export const MODE_OPTION = Object.freeze({
|
||||
|
||||
@@ -4,16 +4,16 @@ const VmessSecurityEnum = z.enum([
|
||||
'aes-128-gcm',
|
||||
'chacha20-poly1305',
|
||||
'auto',
|
||||
'none',
|
||||
'zero',
|
||||
]);
|
||||
|
||||
// Legacy rows persisted `security: ""` (especially on VMess inbounds
|
||||
// created before the enum was nailed down). Preprocess maps the empty
|
||||
// string back to the documented default so existing data parses cleanly
|
||||
// — subsequent writes serialize the normalized value.
|
||||
// created before the enum was nailed down), and rows predating xray-core
|
||||
// v26.7.11 may still hold the removed "none"/"zero" values that the core
|
||||
// now treats as "auto". Preprocess maps all of them to the documented
|
||||
// default so existing data parses cleanly — subsequent writes serialize
|
||||
// the normalized value.
|
||||
export const VmessSecuritySchema = z.preprocess(
|
||||
(val) => (val === '' ? 'auto' : val),
|
||||
(val) => (val === '' || val === 'none' || val === 'zero' ? 'auto' : val),
|
||||
VmessSecurityEnum,
|
||||
);
|
||||
export type VmessSecurity = z.infer<typeof VmessSecurityEnum>;
|
||||
|
||||
@@ -5,12 +5,12 @@ import { z } from 'zod';
|
||||
// plus optional QUIC tuning. The `settings` sub-object is polymorphic on
|
||||
// `type`; we model the wire-faithful shape with a permissive
|
||||
// record-of-unknown for `settings` and leave per-type tightening to
|
||||
// Step 6 — there are 8 UDP mask types plus 3 TCP mask types, each with
|
||||
// Step 6 — there are 8 UDP mask types plus 4 TCP mask types, each with
|
||||
// distinct setting fields, and modeling them all as discriminated unions
|
||||
// here would dwarf the rest of the stream module without buying anything
|
||||
// the safety net doesn't already cover.
|
||||
|
||||
export const TcpMaskTypeSchema = z.enum(['fragment', 'sudoku', 'header-custom']);
|
||||
export const TcpMaskTypeSchema = z.enum(['fragment', 'sudoku', 'header-custom', 'xmc']);
|
||||
export type TcpMaskType = z.infer<typeof TcpMaskTypeSchema>;
|
||||
|
||||
export const TcpMaskSchema = z.object({
|
||||
|
||||
@@ -52,10 +52,28 @@ const TransportNetworkSettingsSchema = z.discriminatedUnion('network', [
|
||||
// mode. The transportless branch accepts that shape (network absent), while a
|
||||
// present-but-invalid network still fails both branches so a typo can't slip
|
||||
// through. `network: never().optional()` reads as "this key must be absent".
|
||||
export const NetworkSettingsSchema = z.union([
|
||||
TransportNetworkSettingsSchema,
|
||||
z.object({ network: z.never().optional() }),
|
||||
]);
|
||||
//
|
||||
// The preprocess folds `method` — xray-core v26.7.11's preferred alias for
|
||||
// `network`, which wins over `network` when both are present — back into the
|
||||
// panel-canonical `network` key, so imported/pasted configs keyed on the
|
||||
// alias don't silently match the transportless branch and lose their
|
||||
// transport.
|
||||
export const NetworkSettingsSchema = z.preprocess(
|
||||
(val) => {
|
||||
if (val && typeof val === 'object' && 'method' in val) {
|
||||
const { method, ...rest } = val as Record<string, unknown>;
|
||||
if (typeof method === 'string' && method !== '') {
|
||||
return { ...rest, network: method };
|
||||
}
|
||||
return rest;
|
||||
}
|
||||
return val;
|
||||
},
|
||||
z.union([
|
||||
TransportNetworkSettingsSchema,
|
||||
z.object({ network: z.never().optional() }),
|
||||
]),
|
||||
);
|
||||
export type NetworkSettings = z.infer<typeof NetworkSettingsSchema>;
|
||||
|
||||
// Orthogonal extras that ride alongside the network and security branches.
|
||||
|
||||
@@ -15,9 +15,14 @@ export type XHttpMode = z.infer<typeof XHttpModeSchema>;
|
||||
// XMUX is the connection-multiplexing layer xHTTP uses to fan out
|
||||
// parallel requests over a small pool of upstream connections. Fields
|
||||
// are strings because they accept dash-range values like '16-32'.
|
||||
// maxConcurrency and maxConnections are mutually exclusive strategies
|
||||
// (xray-core rejects a config that sets both), so the bare schema
|
||||
// default keeps only one of them non-zero — a non-zero maxConnections
|
||||
// default resurrected on load made every re-save silently delete the
|
||||
// user's maxConcurrency.
|
||||
export const XHttpXmuxSchema = z.object({
|
||||
maxConcurrency: z.string().default('16-32'),
|
||||
maxConnections: z.union([z.string(), z.number()]).default(6),
|
||||
maxConnections: z.union([z.string(), z.number()]).default(0),
|
||||
cMaxReuseTimes: z.union([z.string(), z.number()]).default(0),
|
||||
hMaxRequestTimes: z.string().default('600-900'),
|
||||
hMaxReusableSecs: z.string().default('1800-3000'),
|
||||
@@ -25,6 +30,15 @@ export const XHttpXmuxSchema = z.object({
|
||||
});
|
||||
export type XHttpXmux = z.infer<typeof XHttpXmuxSchema>;
|
||||
|
||||
// Seed for freshly enabling XMUX on a config that had no xmux block:
|
||||
// mirrors xray-core v26.6.27's own anti-RKN maxConnections=6 fallback
|
||||
// rather than the concurrency strategy.
|
||||
export const XMUX_FRESH_DEFAULTS: XHttpXmux = {
|
||||
...XHttpXmuxSchema.parse({}),
|
||||
maxConcurrency: '',
|
||||
maxConnections: 6,
|
||||
};
|
||||
|
||||
// Predefined sessionIDTable names xray-core accepts as a shorthand for a
|
||||
// charset (splithttp.PredefinedTable, xray-core #6258). A literal ASCII
|
||||
// charset string is also accepted.
|
||||
|
||||
@@ -78,6 +78,15 @@ export const OutboundTestResultSchema = z.object({
|
||||
}).loose(),
|
||||
)
|
||||
.optional(),
|
||||
egress: z
|
||||
.object({
|
||||
ipv4: z.string().optional(),
|
||||
ipv6: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
warp: z.string().optional(),
|
||||
})
|
||||
.loose()
|
||||
.optional(),
|
||||
}).loose();
|
||||
|
||||
// Batch results from /xray/testOutbounds, aligned with the request order.
|
||||
|
||||
@@ -167,6 +167,16 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses tcp-mask byte-stably 1`
|
||||
},
|
||||
"type": "header-custom",
|
||||
},
|
||||
{
|
||||
"settings": {
|
||||
"hostname": "mc.example.com",
|
||||
"password": "s3cr3t",
|
||||
"usernames": [
|
||||
"Dream",
|
||||
],
|
||||
},
|
||||
"type": "xmc",
|
||||
},
|
||||
],
|
||||
"udp": [],
|
||||
}
|
||||
|
||||
@@ -25,6 +25,14 @@
|
||||
],
|
||||
"errors": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "xmc",
|
||||
"settings": {
|
||||
"hostname": "mc.example.com",
|
||||
"usernames": ["Dream"],
|
||||
"password": "s3cr3t"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -353,3 +353,35 @@ describe('legacy xhttp session keys on edit (#5621)', () => {
|
||||
expect(out.sessionKey).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('xhttp xmux maxConcurrency survives a load/re-save round-trip', () => {
|
||||
const xmuxRow: RawInboundRow = {
|
||||
...vlessRow,
|
||||
streamSettings: {
|
||||
network: 'xhttp',
|
||||
security: 'none',
|
||||
xhttpSettings: {
|
||||
path: '/xh',
|
||||
mode: 'auto',
|
||||
xmux: { maxConcurrency: '1-2' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it('rawInboundToFormValues does not resurrect a non-zero maxConnections', () => {
|
||||
const values = rawInboundToFormValues(xmuxRow);
|
||||
const xhttp = (values.streamSettings as unknown as Record<string, Record<string, unknown>>).xhttpSettings;
|
||||
expect(xhttp.enableXmux).toBe(true);
|
||||
const xmux = xhttp.xmux as Record<string, unknown>;
|
||||
expect(xmux.maxConcurrency).toBe('1-2');
|
||||
expect(xmux.maxConnections).toBe(0);
|
||||
});
|
||||
|
||||
it('formValuesToWirePayload keeps maxConcurrency on an unedited re-save', () => {
|
||||
const values = rawInboundToFormValues(xmuxRow);
|
||||
const payload = formValuesToWirePayload(values);
|
||||
const stream = JSON.parse(payload.streamSettings) as Record<string, Record<string, unknown>>;
|
||||
const xmux = stream.xhttpSettings.xmux as Record<string, unknown>;
|
||||
expect(xmux.maxConcurrency).toBe('1-2');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -521,6 +521,29 @@ describe('outbound-form-adapter: xhttp xmux toggle', () => {
|
||||
const wireXhttp = (back.streamSettings as Record<string, unknown>).xhttpSettings as Record<string, unknown>;
|
||||
expect(wireXhttp).not.toHaveProperty('xmux');
|
||||
});
|
||||
|
||||
it('keeps a saved maxConcurrency through a load/re-save round-trip', () => {
|
||||
const wire = {
|
||||
...xmuxWire,
|
||||
streamSettings: {
|
||||
...xmuxWire.streamSettings,
|
||||
xhttpSettings: {
|
||||
...xmuxWire.streamSettings.xhttpSettings,
|
||||
xmux: { maxConcurrency: '1-2' },
|
||||
},
|
||||
},
|
||||
};
|
||||
const form = rawOutboundToFormValues(wire);
|
||||
const xhttp = (form.streamSettings as Record<string, unknown>).xhttpSettings as Record<string, unknown>;
|
||||
const xmux = xhttp.xmux as Record<string, unknown>;
|
||||
expect(xmux.maxConcurrency).toBe('1-2');
|
||||
expect(xmux.maxConnections).toBe(0);
|
||||
|
||||
const back = formValuesToWirePayload(form);
|
||||
const wireXhttp = (back.streamSettings as Record<string, unknown>).xhttpSettings as Record<string, unknown>;
|
||||
const wireXmux = wireXhttp.xmux as Record<string, unknown>;
|
||||
expect(wireXmux.maxConcurrency).toBe('1-2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('outbound-form-adapter: full optional-block round-trip', () => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { InboundFormSchema } from '@/schemas/forms/inbound-form';
|
||||
import { HappyEyeballsSchema } from '@/schemas/protocols/stream/sockopt';
|
||||
import type { InboundFormValues } from '@/schemas/forms/inbound-form';
|
||||
import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
|
||||
import { XHttpXmuxSchema, XMUX_FRESH_DEFAULTS } from '@/schemas/protocols/stream/xhttp';
|
||||
|
||||
describe('validateRealityTarget', () => {
|
||||
it('accepts host:port and bare port', () => {
|
||||
@@ -153,19 +153,25 @@ describe('normalizeXhttpForWire stream-one', () => {
|
||||
expect(xmux.maxConnections).toBe('8');
|
||||
});
|
||||
|
||||
it('defaults xmux maxConnections to 6 (xray-core anti-RKN default) and drops maxConcurrency on the wire', () => {
|
||||
expect(XHttpXmuxSchema.parse({}).maxConnections).toBe(6);
|
||||
it('bare schema defaults keep only one exclusive xmux strategy non-zero', () => {
|
||||
expect(XHttpXmuxSchema.parse({}).maxConnections).toBe(0);
|
||||
expect(XHttpXmuxSchema.parse({}).maxConcurrency).toBe('16-32');
|
||||
});
|
||||
|
||||
it('XMUX_FRESH_DEFAULTS seeds the anti-RKN maxConnections=6 without a competing maxConcurrency', () => {
|
||||
expect(XMUX_FRESH_DEFAULTS.maxConnections).toBe(6);
|
||||
expect(XMUX_FRESH_DEFAULTS.maxConcurrency).toBe('');
|
||||
|
||||
const out = normalizeXhttpForWire({
|
||||
path: '/app',
|
||||
mode: 'stream-one',
|
||||
enableXmux: true,
|
||||
xmux: XHttpXmuxSchema.parse({}),
|
||||
xmux: XMUX_FRESH_DEFAULTS,
|
||||
}, 'outbound');
|
||||
|
||||
const xmux = out.xmux as Record<string, unknown>;
|
||||
expect(xmux.maxConnections).toBe(6);
|
||||
expect(xmux).not.toHaveProperty('maxConcurrency');
|
||||
expect(xmux.maxConcurrency).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -24,3 +24,16 @@ describe('NetworkSettingsSchema fixtures', () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('NetworkSettingsSchema method alias', () => {
|
||||
it('folds xray-core v26.7.11 method alias back into network', () => {
|
||||
const parsed = NetworkSettingsSchema.parse({ method: 'ws', wsSettings: {} });
|
||||
expect((parsed as { network?: string }).network).toBe('ws');
|
||||
expect((parsed as Record<string, unknown>).method).toBeUndefined();
|
||||
});
|
||||
|
||||
it('prefers method over network when both are present', () => {
|
||||
const parsed = NetworkSettingsSchema.parse({ method: 'grpc', network: 'tcp', grpcSettings: {} });
|
||||
expect((parsed as { network?: string }).network).toBe('grpc');
|
||||
});
|
||||
});
|
||||
|
||||
+13
-1
@@ -77,6 +77,18 @@ function injectBasePathPlugin() {
|
||||
};
|
||||
}
|
||||
|
||||
// Cloudflare Rocket Loader rewrites script tags and runs bundles through its
|
||||
// own loader, breaking ES-module semantics; data-cfasync="false" opts out.
|
||||
function rocketLoaderOptOutPlugin() {
|
||||
return {
|
||||
name: 'xui-rocket-loader-opt-out',
|
||||
apply: 'build',
|
||||
transformIndexHtml(html) {
|
||||
return html.replaceAll('<script ', '<script data-cfasync="false" ');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function bypassMigratedRoute(req) {
|
||||
if (req.method !== 'GET') return undefined;
|
||||
const url = req.url.split('?')[0];
|
||||
@@ -140,7 +152,7 @@ function makeBackendProxy(target) {
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), injectBasePathPlugin()],
|
||||
plugins: [react(), injectBasePathPlugin(), rocketLoaderOptOutPlugin()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
|
||||
@@ -21,10 +21,10 @@ require (
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/valyala/fasthttp v1.72.0
|
||||
github.com/xlzd/gotp v0.1.0
|
||||
github.com/xtls/xray-core v1.260327.1-0.20260627131803-45cf2898ab12
|
||||
github.com/xtls/xray-core v1.260327.1-0.20260711155151-50231eaff98c
|
||||
go.uber.org/atomic v1.11.0
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/sys v0.46.0
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/sys v0.47.0
|
||||
golang.org/x/text v0.40.0
|
||||
google.golang.org/grpc v1.82.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
@@ -64,7 +64,7 @@ require (
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/juju/ratelimit v1.0.2 // indirect
|
||||
github.com/klauspost/compress v1.19.0
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
@@ -77,7 +77,7 @@ require (
|
||||
github.com/pion/logging v0.2.4 // indirect
|
||||
github.com/pion/stun/v3 v3.1.6 // indirect
|
||||
github.com/pion/transport/v4 v4.0.2 // indirect
|
||||
github.com/pires/go-proxyproto v0.12.0 // indirect
|
||||
github.com/pires/go-proxyproto v0.15.0 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.60.0 // indirect
|
||||
@@ -101,7 +101,7 @@ require (
|
||||
golang.org/x/arch v0.28.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect
|
||||
golang.org/x/mod v0.37.0 // indirect
|
||||
golang.org/x/net v0.56.0
|
||||
golang.org/x/net v0.57.0
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
golang.org/x/tools v0.47.0 // indirect
|
||||
|
||||
@@ -117,8 +117,8 @@ github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI=
|
||||
github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk=
|
||||
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
|
||||
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
|
||||
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
@@ -156,8 +156,8 @@ github.com/pion/stun/v3 v3.1.6 h1:WnhsD0eHCiwCfKNkVx0VJJwr2Y3eV4Ueih3KJ+dfZy8=
|
||||
github.com/pion/stun/v3 v3.1.6/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs=
|
||||
github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk=
|
||||
github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
|
||||
github.com/pires/go-proxyproto v0.12.0 h1:TTCxD66dU898tahivkqc3hoceZp7P44FnorWyo9d5vM=
|
||||
github.com/pires/go-proxyproto v0.12.0/go.mod h1:qUvfqUMEoX7T8g0q7TQLDnhMjdTrxnG0hvpMn+7ePNI=
|
||||
github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI=
|
||||
github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
@@ -218,8 +218,8 @@ github.com/xlzd/gotp v0.1.0 h1:37blvlKCh38s+fkem+fFh7sMnceltoIEBYTVXyoa5Po=
|
||||
github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg=
|
||||
github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f h1:iy2JRioxmUpoJ3SzbFPyTxHZMbR/rSHP7dOOgYaq1O8=
|
||||
github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f/go.mod h1:DsJblcWDGt76+FVqBVwbwRhxyyNJsGV48gJLch0OOWI=
|
||||
github.com/xtls/xray-core v1.260327.1-0.20260627131803-45cf2898ab12 h1:0RZBYuLmkxxlMrSsCNRrdNWHhW5zHTfkC2vAknzZ5P4=
|
||||
github.com/xtls/xray-core v1.260327.1-0.20260627131803-45cf2898ab12/go.mod h1:5J11wfaLGhI6HNOi90zVI6E87c1ykA8grPfUeHZYbhA=
|
||||
github.com/xtls/xray-core v1.260327.1-0.20260711155151-50231eaff98c h1:SbB1ez0bqZllbzaVj0PC+Vje3dRA8m/7jW1ussjDSgM=
|
||||
github.com/xtls/xray-core v1.260327.1-0.20260711155151-50231eaff98c/go.mod h1:Jts8yHqPCpvsdL5CW5xMd8H9d2fkg1cILeBNqEwRXNw=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
@@ -248,14 +248,14 @@ go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBs
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
||||
golang.org/x/arch v0.28.0 h1:wVwVdqsTuUbJvhYVCspQYwZXHNYeLSoZnmHD+ggddpQ=
|
||||
golang.org/x/arch v0.28.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -263,8 +263,8 @@ golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
|
||||
+18
@@ -873,6 +873,24 @@ prompt_and_setup_ssl() {
|
||||
# User chose Let's Encrypt IP certificate option
|
||||
echo -e "${green}Using Let's Encrypt for IP certificate (shortlived profile)...${plain}"
|
||||
|
||||
# Confirm the auto-detected IP before issuing for it: with asymmetric
|
||||
# routing / multi-WAN the echo services can return a transit address.
|
||||
if [[ "$NONINTERACTIVE" != "1" ]]; then
|
||||
local ip_confirm=""
|
||||
read -rp "Is ${server_ip} the correct incoming public IPv4 address for this server? [Default y]: " ip_confirm
|
||||
if [[ -n "$ip_confirm" && "$ip_confirm" != "y" && "$ip_confirm" != "Y" ]]; then
|
||||
server_ip=""
|
||||
while [[ -z "$server_ip" ]]; do
|
||||
read -rp "Please enter your server's public IPv4 address: " server_ip
|
||||
server_ip="${server_ip// /}"
|
||||
if [[ ! "$server_ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo -e "${red}Invalid IPv4 address. Please try again.${plain}"
|
||||
server_ip=""
|
||||
fi
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
# Ask for optional IPv6
|
||||
local ipv6_addr=""
|
||||
prompt_or_default ipv6_addr "Do you have an IPv6 address to include? (leave empty to skip): " "" XUI_SSL_IPV6
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.4.2
|
||||
3.5.0
|
||||
+271
-7
@@ -56,8 +56,8 @@ const (
|
||||
defaultPassword = "admin"
|
||||
)
|
||||
|
||||
func initModels() error {
|
||||
models := []any{
|
||||
func allModels() []any {
|
||||
return []any{
|
||||
&model.User{},
|
||||
&model.Inbound{},
|
||||
&model.OutboundTraffics{},
|
||||
@@ -78,12 +78,16 @@ func initModels() error {
|
||||
&model.ClientGlobalTraffic{},
|
||||
&model.OutboundSubscription{},
|
||||
}
|
||||
}
|
||||
|
||||
func initModels() error {
|
||||
models := allModels()
|
||||
for _, mdl := range models {
|
||||
if IsPostgres() && postgresModelSettled(mdl) {
|
||||
continue
|
||||
}
|
||||
if err := db.AutoMigrate(mdl); err != nil {
|
||||
if isIgnorableDuplicateColumnErr(err, mdl) {
|
||||
if isIgnorableDuplicateColumnErr(db, err, mdl) {
|
||||
log.Printf("Ignoring duplicate column during auto migration for %T: %v", mdl, err)
|
||||
continue
|
||||
}
|
||||
@@ -91,6 +95,9 @@ func initModels() error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := dropLegacyInboundPortUnique(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migrateHostVerifyPeerCertByNameColumn(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -118,6 +125,12 @@ func initModels() error {
|
||||
if err := migrateLegacySocksInboundsToMixed(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migrateShadowsocksRemovedCiphers(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migrateVmessRemovedSecurities(); err != nil {
|
||||
return err
|
||||
}
|
||||
if IsPostgres() {
|
||||
if err := resyncPostgresSequences(db, models); err != nil {
|
||||
log.Printf("Error resyncing postgres sequences: %v", err)
|
||||
@@ -160,6 +173,121 @@ func dropLegacyForeignKeys() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type sqliteIndexListRow struct {
|
||||
Name string `gorm:"column:name"`
|
||||
Unique int `gorm:"column:unique"`
|
||||
Origin string `gorm:"column:origin"`
|
||||
}
|
||||
|
||||
func sqliteUniquePortIndexes() (autoIndexes, explicitIndexes []string, err error) {
|
||||
var list []sqliteIndexListRow
|
||||
if err = db.Raw(`PRAGMA index_list('inbounds')`).Scan(&list).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, idx := range list {
|
||||
if idx.Unique != 1 {
|
||||
continue
|
||||
}
|
||||
var cols []struct {
|
||||
Name string `gorm:"column:name"`
|
||||
}
|
||||
if err = db.Raw(`PRAGMA index_info("` + idx.Name + `")`).Scan(&cols).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(cols) != 1 || cols[0].Name != "port" {
|
||||
continue
|
||||
}
|
||||
if idx.Origin == "c" {
|
||||
explicitIndexes = append(explicitIndexes, idx.Name)
|
||||
} else {
|
||||
autoIndexes = append(autoIndexes, idx.Name)
|
||||
}
|
||||
}
|
||||
return autoIndexes, explicitIndexes, nil
|
||||
}
|
||||
|
||||
// dropLegacyInboundPortUnique removes the pre-multi-node UNIQUE on inbounds.port,
|
||||
// which AutoMigrate never drops and which blocks cross-node port reuse on old SQLite DBs.
|
||||
func dropLegacyInboundPortUnique() error {
|
||||
if IsPostgres() {
|
||||
return nil
|
||||
}
|
||||
autoIndexes, explicitIndexes, err := sqliteUniquePortIndexes()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, name := range explicitIndexes {
|
||||
if err := db.Exec(`DROP INDEX IF EXISTS "` + name + `"`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(autoIndexes) == 0 {
|
||||
return nil
|
||||
}
|
||||
log.Printf("Rebuilding inbounds table to drop the legacy UNIQUE constraint on port")
|
||||
return rebuildInboundsWithoutInlineUniquePort()
|
||||
}
|
||||
|
||||
func sqliteTableColumns(tx *gorm.DB, table string) ([]string, error) {
|
||||
var rows []struct {
|
||||
Name string `gorm:"column:name"`
|
||||
}
|
||||
if err := tx.Raw(`PRAGMA table_info("` + table + `")`).Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cols := make([]string, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
cols = append(cols, r.Name)
|
||||
}
|
||||
return cols, nil
|
||||
}
|
||||
|
||||
func rebuildInboundsWithoutInlineUniquePort() error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
var list []sqliteIndexListRow
|
||||
if err := tx.Raw(`PRAGMA index_list('inbounds')`).Scan(&list).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, idx := range list {
|
||||
if idx.Origin != "c" {
|
||||
continue
|
||||
}
|
||||
if err := tx.Exec(`DROP INDEX IF EXISTS "` + idx.Name + `"`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Exec(`ALTER TABLE inbounds RENAME TO inbounds_legacy_rebuild`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Migrator().CreateTable(&model.Inbound{}); err != nil {
|
||||
return err
|
||||
}
|
||||
newCols, err := sqliteTableColumns(tx, "inbounds")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
oldCols, err := sqliteTableColumns(tx, "inbounds_legacy_rebuild")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
oldSet := make(map[string]struct{}, len(oldCols))
|
||||
for _, c := range oldCols {
|
||||
oldSet[c] = struct{}{}
|
||||
}
|
||||
shared := make([]string, 0, len(newCols))
|
||||
for _, c := range newCols {
|
||||
if _, ok := oldSet[c]; ok {
|
||||
shared = append(shared, `"`+c+`"`)
|
||||
}
|
||||
}
|
||||
colList := strings.Join(shared, ", ")
|
||||
if err := tx.Exec(`INSERT INTO inbounds (` + colList + `) SELECT ` + colList + ` FROM inbounds_legacy_rebuild`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Exec(`DROP TABLE inbounds_legacy_rebuild`).Error
|
||||
})
|
||||
}
|
||||
|
||||
func migrateHostVerifyPeerCertByNameColumn() error {
|
||||
if !db.Migrator().HasColumn(&model.Host{}, "verify_peer_cert_by_name") {
|
||||
return nil
|
||||
@@ -636,6 +764,125 @@ func migrateLegacySocksInboundsToMixed() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateShadowsocksRemovedCiphers rewrites shadowsocks inbounds still using
|
||||
// the "none"/"plain" ciphers that xray-core v26.7.11 removed; one such row
|
||||
// makes the whole generated config unbuildable and keeps xray from starting.
|
||||
func migrateShadowsocksRemovedCiphers() error {
|
||||
var inbounds []model.Inbound
|
||||
if err := db.Where("protocol = ?", model.Shadowsocks).Find(&inbounds).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
migrated := int64(0)
|
||||
for _, inbound := range inbounds {
|
||||
if strings.TrimSpace(inbound.Settings) == "" {
|
||||
continue
|
||||
}
|
||||
var settings map[string]any
|
||||
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
|
||||
continue
|
||||
}
|
||||
changed := false
|
||||
if method, _ := settings["method"].(string); method != "" {
|
||||
if replacement, removed := model.ReplaceRemovedShadowsocksCipher(method); removed {
|
||||
settings["method"] = replacement
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if clients, ok := settings["clients"].([]any); ok {
|
||||
for i := range clients {
|
||||
cm, ok := clients[i].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
method, _ := cm["method"].(string)
|
||||
if replacement, removed := model.ReplaceRemovedShadowsocksCipher(method); removed {
|
||||
cm["method"] = replacement
|
||||
clients[i] = cm
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
continue
|
||||
}
|
||||
newSettings, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
log.Printf("migrateShadowsocksRemovedCiphers: skip inbound %d (marshal failed): %v", inbound.Id, err)
|
||||
continue
|
||||
}
|
||||
if err := db.Model(&model.Inbound{}).Where("id = ?", inbound.Id).
|
||||
Update("settings", string(newSettings)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
migrated++
|
||||
}
|
||||
if migrated > 0 {
|
||||
log.Printf("Rewrote removed shadowsocks cipher on %d inbound(s)", migrated)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateVmessRemovedSecurities rewrites the vmess "none"/"zero" security
|
||||
// values that xray-core v26.7.11 removed to "auto" (what the core now treats
|
||||
// them as), on both the clients column and each vmess inbound's settings.
|
||||
func migrateVmessRemovedSecurities() error {
|
||||
res := db.Exec("UPDATE clients SET security = 'auto' WHERE security IN ('none', 'zero')")
|
||||
if res.Error != nil {
|
||||
log.Printf("Error migrating removed vmess security values on clients: %v", res.Error)
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected > 0 {
|
||||
log.Printf("Migrated %d client(s) off removed vmess security values", res.RowsAffected)
|
||||
}
|
||||
var inbounds []model.Inbound
|
||||
if err := db.Where("protocol = ?", model.VMESS).Find(&inbounds).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
migrated := int64(0)
|
||||
for _, inbound := range inbounds {
|
||||
if strings.TrimSpace(inbound.Settings) == "" {
|
||||
continue
|
||||
}
|
||||
var settings map[string]any
|
||||
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
|
||||
continue
|
||||
}
|
||||
clients, ok := settings["clients"].([]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
changed := false
|
||||
for i := range clients {
|
||||
cm, ok := clients[i].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if security, _ := cm["security"].(string); security == "none" || security == "zero" {
|
||||
cm["security"] = "auto"
|
||||
clients[i] = cm
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
continue
|
||||
}
|
||||
newSettings, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
log.Printf("migrateVmessRemovedSecurities: skip inbound %d (marshal failed): %v", inbound.Id, err)
|
||||
continue
|
||||
}
|
||||
if err := db.Model(&model.Inbound{}).Where("id = ?", inbound.Id).
|
||||
Update("settings", string(newSettings)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
migrated++
|
||||
}
|
||||
if migrated > 0 {
|
||||
log.Printf("Rewrote removed vmess security values on %d inbound(s)", migrated)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeInboundSubSortIndex lifts sub_sort_index values below the 1-based
|
||||
// minimum (rows written by builds that defaulted the column to 0, or by nodes
|
||||
// predating the field) so they cannot sort ahead of explicitly ranked inbounds.
|
||||
@@ -756,7 +1003,7 @@ func dedupeInboundSettingsClients() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func isIgnorableDuplicateColumnErr(err error, mdl any) bool {
|
||||
func isIgnorableDuplicateColumnErr(gdb *gorm.DB, err error, mdl any) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
@@ -766,14 +1013,14 @@ func isIgnorableDuplicateColumnErr(err error, mdl any) bool {
|
||||
if _, after, ok := strings.Cut(errMsg, sqlitePrefix); ok {
|
||||
col := strings.TrimSpace(after)
|
||||
col = strings.Trim(col, "`\"[]")
|
||||
return col != "" && db != nil && db.Migrator().HasColumn(mdl, col)
|
||||
return col != "" && gdb != nil && gdb.Migrator().HasColumn(mdl, col)
|
||||
}
|
||||
if strings.Contains(errMsg, "already exists") && strings.Contains(errMsg, "column ") {
|
||||
if _, after, ok := strings.Cut(errMsg, "column \""); ok {
|
||||
rest := after
|
||||
if e := strings.Index(rest, "\""); e > 0 {
|
||||
col := rest[:e]
|
||||
return col != "" && db != nil && db.Migrator().HasColumn(mdl, col)
|
||||
return col != "" && gdb != nil && gdb.Migrator().HasColumn(mdl, col)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -810,7 +1057,7 @@ func runSeeders(isUsersEmpty bool) error {
|
||||
}
|
||||
|
||||
if empty && isUsersEmpty {
|
||||
seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "ApiTokensHash", "LegacyProxySettingsCleanup", "WireguardPeersToClients", "MtprotoSecretsToClients"}
|
||||
seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "ApiTokensHash", "LegacyProxySettingsCleanup", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted"}
|
||||
for _, name := range seeders {
|
||||
if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
|
||||
return err
|
||||
@@ -903,6 +1150,12 @@ func runSeeders(isUsersEmpty bool) error {
|
||||
}
|
||||
}
|
||||
|
||||
if !slices.Contains(seedersHistory, "NodeInboundsAdopted") {
|
||||
if err := seedNodeInboundsAdopted(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := seedHostsFromExternalProxy(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -937,6 +1190,17 @@ func runSeeders(isUsersEmpty bool) error {
|
||||
return normalizeSettingPaths()
|
||||
}
|
||||
|
||||
// seedNodeInboundsAdopted keeps the pre-existing reconcile behavior for nodes
|
||||
// that were already syncing before the inbounds_adopted_at gate was introduced.
|
||||
func seedNodeInboundsAdopted() error {
|
||||
if err := db.Model(&model.Node{}).
|
||||
Where("inbounds_adopted_at = 0").
|
||||
Update("inbounds_adopted_at", time.Now().Unix()).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Create(&model.HistoryOfSeeders{SeederName: "NodeInboundsAdopted"}).Error
|
||||
}
|
||||
|
||||
func seedHostGroupIds() error {
|
||||
var history []string
|
||||
if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &history).Error; err != nil {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const legacyInboundsDDL = "CREATE TABLE `inbounds` (`id` integer PRIMARY KEY AUTOINCREMENT,`user_id` integer,`up` integer,`down` integer,`total` integer,`remark` text,`enable` numeric,`expiry_time` integer,`listen` text,`port` integer UNIQUE,`protocol` text,`settings` text,`stream_settings` text,`tag` text UNIQUE,`sniffing` text)"
|
||||
|
||||
func openLegacyPortUniqueDB(t *testing.T) string {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "x-ui.db")
|
||||
legacy, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open legacy db: %v", err)
|
||||
}
|
||||
if err := legacy.Exec(legacyInboundsDDL).Error; err != nil {
|
||||
t.Fatalf("create legacy inbounds: %v", err)
|
||||
}
|
||||
if err := legacy.Exec(
|
||||
`INSERT INTO inbounds (user_id, remark, enable, port, protocol, settings, stream_settings, tag, sniffing)
|
||||
VALUES (1, 'preexisting', 1, 80, 'vless', '{"clients":[]}', '{}', 'in-80-tcp', '{}')`,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("seed legacy inbound: %v", err)
|
||||
}
|
||||
sqlDB, err := legacy.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("legacy db handle: %v", err)
|
||||
}
|
||||
if err := sqlDB.Close(); err != nil {
|
||||
t.Fatalf("close legacy db: %v", err)
|
||||
}
|
||||
return dbPath
|
||||
}
|
||||
|
||||
func TestDropLegacyInboundPortUnique(t *testing.T) {
|
||||
dbPath := openLegacyPortUniqueDB(t)
|
||||
|
||||
if err := InitDB(dbPath); err != nil {
|
||||
t.Fatalf("InitDB over legacy schema: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = CloseDB() })
|
||||
|
||||
var preexisting model.Inbound
|
||||
if err := GetDB().Where("tag = ?", "in-80-tcp").First(&preexisting).Error; err != nil {
|
||||
t.Fatalf("preexisting inbound lost in rebuild: %v", err)
|
||||
}
|
||||
if preexisting.Remark != "preexisting" || preexisting.Port != 80 {
|
||||
t.Fatalf("preexisting inbound corrupted: remark=%q port=%d", preexisting.Remark, preexisting.Port)
|
||||
}
|
||||
|
||||
nodeA, nodeB := 1, 2
|
||||
samePortA := &model.Inbound{UserId: 1, Tag: "node-a-80", Enable: true, Port: 80, Protocol: model.VLESS, Remark: "a", Settings: `{"clients":[]}`, NodeID: &nodeA}
|
||||
samePortB := &model.Inbound{UserId: 1, Tag: "node-b-80", Enable: true, Port: 80, Protocol: model.VLESS, Remark: "b", Settings: `{"clients":[]}`, NodeID: &nodeB}
|
||||
if err := GetDB().Create(samePortA).Error; err != nil {
|
||||
t.Fatalf("create node-a inbound on port 80: %v", err)
|
||||
}
|
||||
if err := GetDB().Create(samePortB).Error; err != nil {
|
||||
t.Fatalf("create node-b inbound on port 80: %v", err)
|
||||
}
|
||||
|
||||
var dupTag model.Inbound
|
||||
dupErr := GetDB().Create(&model.Inbound{UserId: 1, Tag: "in-80-tcp", Enable: true, Port: 90, Protocol: model.VLESS, Settings: `{"clients":[]}`}).Error
|
||||
if dupErr == nil {
|
||||
t.Fatalf("duplicate tag insert succeeded, want unique violation; row=%v", dupTag)
|
||||
}
|
||||
|
||||
if err := dropLegacyInboundPortUnique(); err != nil {
|
||||
t.Fatalf("second dropLegacyInboundPortUnique run: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,8 @@ import (
|
||||
// related tests.
|
||||
//
|
||||
// Important: When adding a new top-level model (like OutboundSubscription),
|
||||
// you must add it here **in addition to** the list in internal/database/db.go:initModels().
|
||||
// you must add it here **in addition to** allModels() in internal/database/db.go;
|
||||
// TestMigrationModelsMatchPanelModels fails when the two lists drift apart.
|
||||
// This list is used for:
|
||||
// - Creating the destination schema during cross-DB migration
|
||||
// - Truncating tables
|
||||
@@ -48,17 +49,21 @@ func migrationModels() []any {
|
||||
&model.ClientRecord{},
|
||||
&model.ClientInbound{},
|
||||
&model.ClientExternalLink{},
|
||||
&model.ClientGroup{},
|
||||
&model.InboundFallback{},
|
||||
&model.Host{},
|
||||
&model.NodeClientTraffic{},
|
||||
&model.NodeClientIp{},
|
||||
&model.ClientGlobalTraffic{},
|
||||
&model.OutboundSubscription{},
|
||||
}
|
||||
}
|
||||
|
||||
// MigrateData copies every row from the configured SQLite file at srcPath into
|
||||
// a fresh PostgreSQL database described by dstDSN. The destination tables are
|
||||
// (re)created with AutoMigrate before the copy. Source data is left untouched.
|
||||
// (re)created with AutoMigrate; truncate and copy then run in one transaction,
|
||||
// so a failed migration leaves the destination data unchanged. Source data is
|
||||
// left untouched.
|
||||
func MigrateData(srcPath, dstDSN string) error {
|
||||
if _, err := os.Stat(srcPath); err != nil {
|
||||
return fmt.Errorf("source sqlite not found at %s: %w", srcPath, err)
|
||||
@@ -100,33 +105,41 @@ 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
|
||||
// way a plain INSERT with explicit ids would collide on users_pkey, so clear
|
||||
// our tables (only) before copying.
|
||||
if err := truncatePostgresTables(dst, migrationModels()); err != nil {
|
||||
return fmt.Errorf("clear destination tables: %w", err)
|
||||
}
|
||||
|
||||
totalRows := 0
|
||||
for _, m := range migrationModels() {
|
||||
n, err := copyTable(src, dst, m)
|
||||
if err != nil {
|
||||
return fmt.Errorf("copy %T: %w", m, err)
|
||||
txErr := dst.Transaction(func(tx *gorm.DB) 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 := tx.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)
|
||||
}
|
||||
totalRows += n
|
||||
log.Printf(" %-32s %d rows", reflect.TypeOf(m).Elem().Name(), n)
|
||||
|
||||
// Empty the destination tables before copying: a fresh PostgreSQL DB
|
||||
// already holds an auto-seeded admin (id=1) from any prior panel start,
|
||||
// so a plain INSERT with explicit ids would collide on users_pkey. Only
|
||||
// the panel's own tables are cleared, and a failure anywhere in this
|
||||
// transaction rolls the clear back with everything else.
|
||||
if err := truncatePostgresTables(tx, migrationModels()); err != nil {
|
||||
return fmt.Errorf("clear destination tables: %w", err)
|
||||
}
|
||||
|
||||
for _, m := range migrationModels() {
|
||||
n, err := copyTable(src, tx, m)
|
||||
if err != nil {
|
||||
return fmt.Errorf("copy %T: %w", m, err)
|
||||
}
|
||||
totalRows += n
|
||||
log.Printf(" %-32s %d rows", reflect.TypeOf(m).Elem().Name(), n)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
|
||||
// setval is never rolled back by PostgreSQL, so sequences are resynced only
|
||||
// after the transaction has committed.
|
||||
if err := resetPostgresSequences(dst); err != nil {
|
||||
log.Printf("warning: failed to reset some postgres sequences: %v", err)
|
||||
}
|
||||
@@ -301,3 +314,32 @@ func tableWithIdColumn(db *gorm.DB, m any) (string, bool) {
|
||||
}
|
||||
return stmt.Table, true
|
||||
}
|
||||
|
||||
// PrepareSQLiteForMigration rejects SQLite files that are not a panel database
|
||||
// before the caller causes any downtime, then AutoMigrates the panel schema
|
||||
// onto the file so backups from older versions gain the newer tables and
|
||||
// columns the row copy reads. Data-level upgrades are not needed here: they
|
||||
// run dialect-agnostically on the destination via InitDB after the import.
|
||||
func PrepareSQLiteForMigration(dbPath string) error {
|
||||
gdb, err := gorm.Open(sqlite.Open(dbPath+"?_busy_timeout=10000"), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sqlDB, err := gdb.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
for _, table := range []string{"users", "settings", "inbounds"} {
|
||||
if !sqliteTableExists(sqlDB, table) {
|
||||
return fmt.Errorf("not a 3x-ui panel database: required table %q is missing", table)
|
||||
}
|
||||
}
|
||||
for _, m := range migrationModels() {
|
||||
if err := gdb.AutoMigrate(m); err != nil && !isIgnorableDuplicateColumnErr(gdb, err, m) {
|
||||
return fmt.Errorf("upgrade panel schema for %T: %w", m, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -137,3 +137,73 @@ func TestMigrateData_PreservesFalseDefaultedColumns(t *testing.T) {
|
||||
t.Fatalf("disabled node re-enabled after migration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateData_FailedCopyLeavesDestinationUntouched(t *testing.T) {
|
||||
dsn := os.Getenv("XUI_TEST_PG_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set XUI_TEST_PG_DSN to a reachable Postgres to run this test")
|
||||
}
|
||||
|
||||
seedSource := func(username string) string {
|
||||
t.Helper()
|
||||
srcPath := t.TempDir() + "/x-ui.db"
|
||||
src, err := gorm.Open(sqlite.Open(srcPath), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
for _, m := range migrationModels() {
|
||||
if err := src.AutoMigrate(m); err != nil {
|
||||
t.Fatalf("automigrate %T: %v", m, err)
|
||||
}
|
||||
}
|
||||
if err := src.Create(&model.User{Username: username, Password: "pw"}).Error; err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
if sqlDB, err := src.DB(); err == nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
return srcPath
|
||||
}
|
||||
|
||||
dst, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("open postgres: %v", err)
|
||||
}
|
||||
if err := dst.Migrator().DropTable(migrationModels()...); err != nil {
|
||||
t.Fatalf("drop tables: %v", err)
|
||||
}
|
||||
|
||||
if err := MigrateData(seedSource("keep-me"), dsn); err != nil {
|
||||
t.Fatalf("seed destination via MigrateData: %v", err)
|
||||
}
|
||||
|
||||
brokenSrc := seedSource("evil")
|
||||
breaker, err := gorm.Open(sqlite.Open(brokenSrc), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("reopen broken source: %v", err)
|
||||
}
|
||||
if err := breaker.Exec("DROP TABLE outbound_subscriptions").Error; err != nil {
|
||||
t.Fatalf("drop table from broken source: %v", err)
|
||||
}
|
||||
if sqlDB, err := breaker.DB(); err == nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
|
||||
if err := MigrateData(brokenSrc, dsn); err == nil {
|
||||
t.Fatal("MigrateData succeeded on a source missing outbound_subscriptions, want error")
|
||||
}
|
||||
|
||||
var keepMe, evil int64
|
||||
if err := dst.Model(&model.User{}).Where("username = ?", "keep-me").Count(&keepMe).Error; err != nil {
|
||||
t.Fatalf("count keep-me: %v", err)
|
||||
}
|
||||
if err := dst.Model(&model.User{}).Where("username = ?", "evil").Count(&evil).Error; err != nil {
|
||||
t.Fatalf("count evil: %v", err)
|
||||
}
|
||||
if keepMe != 1 {
|
||||
t.Fatalf("previous destination data lost after failed migration: keep-me count = %d, want 1", keepMe)
|
||||
}
|
||||
if evil != 0 {
|
||||
t.Fatalf("failed migration leaked partial rows: evil count = %d, want 0", evil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMigrationModelsMatchPanelModels(t *testing.T) {
|
||||
names := func(models []any) map[string]bool {
|
||||
set := make(map[string]bool, len(models))
|
||||
for _, m := range models {
|
||||
set[reflect.TypeOf(m).Elem().Name()] = true
|
||||
}
|
||||
return set
|
||||
}
|
||||
panel := names(allModels())
|
||||
migration := names(migrationModels())
|
||||
|
||||
for name := range panel {
|
||||
if !migration[name] {
|
||||
t.Errorf("model %s is in allModels but missing from migrationModels: cross-db migration silently drops its rows", name)
|
||||
}
|
||||
}
|
||||
for name := range migration {
|
||||
if !panel[name] {
|
||||
t.Errorf("model %s is in migrationModels but missing from allModels: its table never exists on a live panel", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -442,8 +442,23 @@ func StripVlessInboundEncryption(settings string) (string, bool) {
|
||||
return string(out), true
|
||||
}
|
||||
|
||||
// HealShadowsocksClientMethods normalises the per-client `method` field
|
||||
// on a shadowsocks inbound's settings JSON before it leaves for xray-core:
|
||||
// ReplaceRemovedShadowsocksCipher maps ciphers that xray-core v26.7.11
|
||||
// deleted ("none"/"plain" make the whole config fail with "unknown cipher
|
||||
// method") to a still-supported replacement. Returns the replacement and
|
||||
// true when the given method is one of the removed ciphers.
|
||||
func ReplaceRemovedShadowsocksCipher(method string) (string, bool) {
|
||||
switch method {
|
||||
case "none", "plain":
|
||||
return "chacha20-ietf-poly1305", true
|
||||
}
|
||||
return method, false
|
||||
}
|
||||
|
||||
// HealShadowsocksClientMethods normalises the `method` fields on a
|
||||
// shadowsocks inbound's settings JSON before it leaves for xray-core:
|
||||
// - Ciphers removed upstream (none/plain): rewritten via
|
||||
// ReplaceRemovedShadowsocksCipher so one legacy row cannot prevent
|
||||
// xray from starting.
|
||||
// - Legacy ciphers (aes-*, chacha20-*): every client must carry a
|
||||
// per-user `method` matching the inbound's top-level method, otherwise
|
||||
// xray fails with "unsupported cipher method:".
|
||||
@@ -462,12 +477,24 @@ func HealShadowsocksClientMethods(settings string) (string, bool) {
|
||||
return settings, false
|
||||
}
|
||||
method, _ := parsed["method"].(string)
|
||||
changed := false
|
||||
if replacement, removed := ReplaceRemovedShadowsocksCipher(method); removed {
|
||||
method = replacement
|
||||
parsed["method"] = method
|
||||
changed = true
|
||||
}
|
||||
clients, ok := parsed["clients"].([]any)
|
||||
if !ok {
|
||||
return settings, false
|
||||
if !changed {
|
||||
return settings, false
|
||||
}
|
||||
out, err := json.MarshalIndent(parsed, "", " ")
|
||||
if err != nil {
|
||||
return settings, false
|
||||
}
|
||||
return string(out), true
|
||||
}
|
||||
is2022 := strings.HasPrefix(method, "2022-blake3-")
|
||||
changed := false
|
||||
for i := range clients {
|
||||
cm, ok := clients[i].(map[string]any)
|
||||
if !ok {
|
||||
@@ -719,6 +746,10 @@ type Node struct {
|
||||
ConfigDirty bool `json:"configDirty" gorm:"default:false"`
|
||||
ConfigDirtyAt int64 `json:"configDirtyAt"`
|
||||
|
||||
// InboundsAdoptedAt records the first clean traffic sync that imported the
|
||||
// node's pre-existing inbounds; reconcile must not sweep remote tags before it.
|
||||
InboundsAdoptedAt int64 `json:"-" gorm:"column:inbounds_adopted_at;default:0"`
|
||||
|
||||
InboundCount int `json:"inboundCount" gorm:"-" example:"5"`
|
||||
ClientCount int `json:"clientCount" gorm:"-" example:"27"`
|
||||
OnlineCount int `json:"onlineCount" gorm:"-" example:"3"`
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestHealShadowsocksClientMethods_RewritesRemovedCipher covers the last-gate
|
||||
// build-time heal for xray-core v26.7.11's removed "none"/"plain" ciphers: a
|
||||
// row that survives to config generation (restored backup, direct DB edit)
|
||||
// must be rewritten to a supported cipher on both the inbound method and its
|
||||
// clients so one such inbound cannot keep xray from starting.
|
||||
func TestHealShadowsocksClientMethods_RewritesRemovedCipher(t *testing.T) {
|
||||
settings := `{"method": "plain", "clients": [{"email": "a@x", "password": "p", "method": "plain"}]}`
|
||||
healed, ok := HealShadowsocksClientMethods(settings)
|
||||
if !ok {
|
||||
t.Fatal("expected heal to report a change for a removed cipher")
|
||||
}
|
||||
var parsed struct {
|
||||
Method string `json:"method"`
|
||||
Clients []map[string]any `json:"clients"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(healed), &parsed); err != nil {
|
||||
t.Fatalf("parse healed settings: %v", err)
|
||||
}
|
||||
if parsed.Method != "chacha20-ietf-poly1305" {
|
||||
t.Fatalf("expected inbound method rewritten to a supported cipher, got %q", parsed.Method)
|
||||
}
|
||||
if parsed.Clients[0]["method"] != "chacha20-ietf-poly1305" {
|
||||
t.Fatalf("expected client method to match the healed cipher, got %v", parsed.Clients[0]["method"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceRemovedShadowsocksCipher(t *testing.T) {
|
||||
for _, method := range []string{"none", "plain"} {
|
||||
if got, removed := ReplaceRemovedShadowsocksCipher(method); !removed || got != "chacha20-ietf-poly1305" {
|
||||
t.Fatalf("ReplaceRemovedShadowsocksCipher(%q) = (%q, %v), want a supported replacement", method, got, removed)
|
||||
}
|
||||
}
|
||||
if got, removed := ReplaceRemovedShadowsocksCipher("aes-256-gcm"); removed || got != "aes-256-gcm" {
|
||||
t.Fatalf("ReplaceRemovedShadowsocksCipher(aes-256-gcm) = (%q, %v), want it left untouched", got, removed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func TestPrepareSQLiteForMigration(t *testing.T) {
|
||||
t.Run("rejects non-panel database", func(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "random.db")
|
||||
gdb, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := gdb.Exec("CREATE TABLE notes(id integer primary key, body text)").Error; err != nil {
|
||||
t.Fatalf("create table: %v", err)
|
||||
}
|
||||
closeGorm(gdb)
|
||||
|
||||
err = PrepareSQLiteForMigration(dbPath)
|
||||
if err == nil {
|
||||
t.Fatal("PrepareSQLiteForMigration accepted a non-panel database, want error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not a 3x-ui panel database") {
|
||||
t.Fatalf("error = %q, want it to contain %q", err.Error(), "not a 3x-ui panel database")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("upgrades old panel schema", func(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "old.db")
|
||||
gdb, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := gdb.AutoMigrate(&model.User{}, &model.Setting{}, &model.Inbound{}); err != nil {
|
||||
t.Fatalf("automigrate legacy subset: %v", err)
|
||||
}
|
||||
closeGorm(gdb)
|
||||
|
||||
if err := PrepareSQLiteForMigration(dbPath); err != nil {
|
||||
t.Fatalf("PrepareSQLiteForMigration rejected an old panel database: %v", err)
|
||||
}
|
||||
|
||||
check, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("reopen sqlite: %v", err)
|
||||
}
|
||||
defer closeGorm(check)
|
||||
sqlDB, err := check.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("sql db: %v", err)
|
||||
}
|
||||
for _, table := range []string{"client_groups", "client_global_traffics", "outbound_subscriptions"} {
|
||||
if !sqliteTableExists(sqlDB, table) {
|
||||
t.Errorf("table %s was not created by the schema upgrade", table)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// TestMigrateShadowsocksRemovedCiphers_RewritesNoneAndPlain covers the
|
||||
// xray-core v26.7.11 removal of the shadowsocks "none"/"plain" ciphers: one
|
||||
// such row makes the generated config unbuildable, so startup must rewrite
|
||||
// both the inbound method and any per-client method to a supported cipher and
|
||||
// leave a valid inbound untouched.
|
||||
func TestMigrateShadowsocksRemovedCiphers_RewritesNoneAndPlain(t *testing.T) {
|
||||
dbDir := t.TempDir()
|
||||
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||
if err := InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
|
||||
t.Fatalf("InitDB failed: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = CloseDB() })
|
||||
|
||||
removed := `{"method": "none", "clients": [{"email": "a@x", "password": "p", "method": "plain"}]}`
|
||||
dirty := model.Inbound{UserId: 1, Port: 31001, Protocol: model.Shadowsocks, Tag: "ss-removed", Settings: removed}
|
||||
if err := db.Create(&dirty).Error; err != nil {
|
||||
t.Fatalf("create dirty inbound: %v", err)
|
||||
}
|
||||
|
||||
valid := `{"method": "aes-256-gcm", "clients": [{"email": "b@x", "password": "p"}]}`
|
||||
clean := model.Inbound{UserId: 1, Port: 31002, Protocol: model.Shadowsocks, Tag: "ss-valid", Settings: valid}
|
||||
if err := db.Create(&clean).Error; err != nil {
|
||||
t.Fatalf("create clean inbound: %v", err)
|
||||
}
|
||||
|
||||
if err := migrateShadowsocksRemovedCiphers(); err != nil {
|
||||
t.Fatalf("migrateShadowsocksRemovedCiphers: %v", err)
|
||||
}
|
||||
|
||||
var gotDirty model.Inbound
|
||||
if err := db.First(&gotDirty, dirty.Id).Error; err != nil {
|
||||
t.Fatalf("reload dirty inbound: %v", err)
|
||||
}
|
||||
var parsed struct {
|
||||
Method string `json:"method"`
|
||||
Clients []map[string]any `json:"clients"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(gotDirty.Settings), &parsed); err != nil {
|
||||
t.Fatalf("parse repaired settings: %v", err)
|
||||
}
|
||||
if parsed.Method != "chacha20-ietf-poly1305" {
|
||||
t.Fatalf("expected inbound method rewritten, got %q", parsed.Method)
|
||||
}
|
||||
if parsed.Clients[0]["method"] != "chacha20-ietf-poly1305" {
|
||||
t.Fatalf("expected client method rewritten, got %v", parsed.Clients[0]["method"])
|
||||
}
|
||||
|
||||
var gotClean model.Inbound
|
||||
if err := db.First(&gotClean, clean.Id).Error; err != nil {
|
||||
t.Fatalf("reload clean inbound: %v", err)
|
||||
}
|
||||
if gotClean.Settings != valid {
|
||||
t.Fatalf("valid inbound was rewritten:\nbefore: %s\nafter: %s", valid, gotClean.Settings)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateVmessRemovedSecurities_RewritesNoneAndZero covers the v26.7.11
|
||||
// removal of vmess "none"/"zero" security values: startup rewrites them to
|
||||
// "auto" on both the clients column and each vmess inbound's settings JSON.
|
||||
func TestMigrateVmessRemovedSecurities_RewritesNoneAndZero(t *testing.T) {
|
||||
dbDir := t.TempDir()
|
||||
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||
if err := InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
|
||||
t.Fatalf("InitDB failed: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = CloseDB() })
|
||||
|
||||
settings := `{"clients": [{"id": "u1", "email": "a@x", "security": "none"},` +
|
||||
`{"id": "u2", "email": "b@x", "security": "zero"},` +
|
||||
`{"id": "u3", "email": "c@x", "security": "aes-128-gcm"}]}`
|
||||
inbound := model.Inbound{UserId: 1, Port: 32001, Protocol: model.VMESS, Tag: "vmess-removed", Settings: settings}
|
||||
if err := db.Create(&inbound).Error; err != nil {
|
||||
t.Fatalf("create vmess inbound: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.ClientRecord{Email: "a@x", Security: "zero", Enable: true}).Error; err != nil {
|
||||
t.Fatalf("create client record: %v", err)
|
||||
}
|
||||
|
||||
if err := migrateVmessRemovedSecurities(); err != nil {
|
||||
t.Fatalf("migrateVmessRemovedSecurities: %v", err)
|
||||
}
|
||||
|
||||
var got model.Inbound
|
||||
if err := db.First(&got, inbound.Id).Error; err != nil {
|
||||
t.Fatalf("reload inbound: %v", err)
|
||||
}
|
||||
var parsed struct {
|
||||
Clients []map[string]any `json:"clients"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(got.Settings), &parsed); err != nil {
|
||||
t.Fatalf("parse settings: %v", err)
|
||||
}
|
||||
if parsed.Clients[0]["security"] != "auto" || parsed.Clients[1]["security"] != "auto" {
|
||||
t.Fatalf("expected removed securities rewritten to auto, got %v", parsed.Clients)
|
||||
}
|
||||
if parsed.Clients[2]["security"] != "aes-128-gcm" {
|
||||
t.Fatalf("expected valid security untouched, got %v", parsed.Clients[2]["security"])
|
||||
}
|
||||
|
||||
var rec model.ClientRecord
|
||||
if err := db.Where("email = ?", "a@x").First(&rec).Error; err != nil {
|
||||
t.Fatalf("reload client record: %v", err)
|
||||
}
|
||||
if rec.Security != "auto" {
|
||||
t.Fatalf("expected client record security rewritten to auto, got %q", rec.Security)
|
||||
}
|
||||
}
|
||||
@@ -236,11 +236,7 @@ func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound,
|
||||
proxy["type"] = "vmess"
|
||||
proxy["uuid"] = client.ID
|
||||
proxy["alterId"] = 0
|
||||
cipher := client.Security
|
||||
if cipher == "" {
|
||||
cipher = "auto"
|
||||
}
|
||||
proxy["cipher"] = cipher
|
||||
proxy["cipher"] = normalizeVmessSecurity(client.Security)
|
||||
case model.VLESS:
|
||||
proxy["type"] = "vless"
|
||||
proxy["uuid"] = applyVlessRoute(client.ID, hostVlessRoute(ep))
|
||||
|
||||
@@ -384,10 +384,7 @@ func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_ut
|
||||
}
|
||||
outbound.StreamSettings = streamSettings
|
||||
|
||||
security := client.Security
|
||||
if security == "" {
|
||||
security = "auto"
|
||||
}
|
||||
security := normalizeVmessSecurity(client.Security)
|
||||
outbound.Settings = map[string]any{
|
||||
"address": inbound.Listen,
|
||||
"port": inbound.Port,
|
||||
|
||||
+14
-1
@@ -714,7 +714,7 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
|
||||
return ""
|
||||
}
|
||||
obj["id"] = client.ID
|
||||
obj["scy"] = client.Security
|
||||
obj["scy"] = normalizeVmessSecurity(client.Security)
|
||||
|
||||
externalProxies, _ := stream["externalProxy"].([]any)
|
||||
|
||||
@@ -726,6 +726,18 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
|
||||
return buildVmessLink(obj)
|
||||
}
|
||||
|
||||
// normalizeVmessSecurity maps the vmess security values xray-core v26.7.11
|
||||
// removed ("none"/"zero"), plus the legacy empty string, to "auto" so links
|
||||
// and subscriptions stop advertising values the upgraded server rejects on
|
||||
// the wire.
|
||||
func normalizeVmessSecurity(security string) string {
|
||||
switch security {
|
||||
case "", "none", "zero":
|
||||
return "auto"
|
||||
}
|
||||
return security
|
||||
}
|
||||
|
||||
// vlessEncryptionEnabled reports whether the VLESS inbound settings enable
|
||||
// VLESS-level encryption (vlessenc / ML-KEM). When on, the encryption/decryption
|
||||
// fields hold a generated dotted string (e.g. "mlkem768x25519plus.native.0rtt.<key>");
|
||||
@@ -2131,6 +2143,7 @@ var validFinalMaskTCPTypes = map[string]struct{}{
|
||||
"header-custom": {},
|
||||
"fragment": {},
|
||||
"sudoku": {},
|
||||
"xmc": {},
|
||||
}
|
||||
|
||||
// applyKcpShareParams reconstructs legacy KCP share-link fields from either
|
||||
|
||||
@@ -1016,6 +1016,21 @@ func TestMarshalFinalMask_UnknownTypeIsDropped(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalFinalMask_KeepsXmcTcpMask(t *testing.T) {
|
||||
fm := map[string]any{
|
||||
"tcp": []any{
|
||||
map[string]any{"type": "xmc", "settings": map[string]any{"password": "p"}},
|
||||
},
|
||||
}
|
||||
out, ok := marshalFinalMask(fm)
|
||||
if !ok {
|
||||
t.Fatal("expected ok=true for an xmc tcp mask")
|
||||
}
|
||||
if !strings.Contains(out, "xmc") {
|
||||
t.Fatalf("marshaled finalmask dropped the xmc mask: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasFinalMaskContent(t *testing.T) {
|
||||
if hasFinalMaskContent(nil) {
|
||||
t.Fatal("nil should not count as content")
|
||||
|
||||
@@ -208,6 +208,10 @@ func parseVmess(link string) (*ParseResult, error) {
|
||||
}
|
||||
|
||||
port := num(j["port"])
|
||||
scy := getString(j, "scy", "auto")
|
||||
if scy == "none" || scy == "zero" {
|
||||
scy = "auto"
|
||||
}
|
||||
ob := Outbound{
|
||||
"protocol": "vmess",
|
||||
"tag": getString(j, "ps", ""),
|
||||
@@ -219,7 +223,7 @@ func parseVmess(link string) (*ParseResult, error) {
|
||||
"users": []any{
|
||||
map[string]any{
|
||||
"id": getString(j, "id", ""),
|
||||
"security": getString(j, "scy", "auto"),
|
||||
"security": scy,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -338,12 +342,15 @@ func parseShadowsocks(link string) (*ParseResult, error) {
|
||||
remark, _ = url.QueryUnescape(link[i+1:])
|
||||
link = link[:i]
|
||||
}
|
||||
if i := strings.Index(link, "?"); i >= 0 {
|
||||
link = link[:i]
|
||||
}
|
||||
core := strings.TrimPrefix(link, "ss://")
|
||||
at := strings.Index(core, "@")
|
||||
if at >= 0 {
|
||||
// modern
|
||||
userB64 := core[:at]
|
||||
hp := core[at+1:]
|
||||
hp := strings.TrimRight(core[at+1:], "/")
|
||||
userInfo, err := base64DecodeFlexible(userB64)
|
||||
if err != nil {
|
||||
// SIP022 (2022-blake3-*) userinfo is percent-encoded, not base64.
|
||||
@@ -358,7 +365,10 @@ func parseShadowsocks(link string) (*ParseResult, error) {
|
||||
return nil, fmt.Errorf("bad ss host:port")
|
||||
}
|
||||
host := hp[:colon]
|
||||
port, _ := strconv.Atoi(hp[colon+1:])
|
||||
port, err := strconv.Atoi(hp[colon+1:])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bad ss port %q: %w", hp[colon+1:], err)
|
||||
}
|
||||
method, pass := splitMethodPass(userInfo)
|
||||
identity := "ss:" + method + ":" + pass + "@" + host + ":" + strconv.Itoa(port)
|
||||
ob := Outbound{
|
||||
@@ -388,7 +398,10 @@ func parseShadowsocks(link string) (*ParseResult, error) {
|
||||
return nil, fmt.Errorf("bad legacy ss hp")
|
||||
}
|
||||
host := hp[:colon]
|
||||
port, _ := strconv.Atoi(hp[colon+1:])
|
||||
port, err := strconv.Atoi(hp[colon+1:])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bad legacy ss port %q: %w", hp[colon+1:], err)
|
||||
}
|
||||
method, pass := splitMethodPass(userInfo)
|
||||
identity := "ss:" + method + ":" + pass + "@" + host + ":" + strconv.Itoa(port)
|
||||
ob := Outbound{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package link
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -113,6 +114,115 @@ func TestSanitizeFinalMaskQuicParams_ClampsAndRejects(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseShadowsocks(t *testing.T) {
|
||||
modernUser := base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:secretpass"))
|
||||
legacyBody := base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:secretpass@1.2.3.4:8388"))
|
||||
cases := []struct {
|
||||
name string
|
||||
link string
|
||||
host string
|
||||
port int
|
||||
method string
|
||||
pass string
|
||||
}{
|
||||
{
|
||||
name: "modern",
|
||||
link: "ss://" + modernUser + "@1.2.3.4:8388#node",
|
||||
host: "1.2.3.4",
|
||||
port: 8388,
|
||||
method: "aes-256-gcm",
|
||||
pass: "secretpass",
|
||||
},
|
||||
{
|
||||
name: "modern with plugin query",
|
||||
link: "ss://" + modernUser + "@1.2.3.4:8388?plugin=v2ray-plugin#node",
|
||||
host: "1.2.3.4",
|
||||
port: 8388,
|
||||
method: "aes-256-gcm",
|
||||
pass: "secretpass",
|
||||
},
|
||||
{
|
||||
name: "modern sip002 slash query",
|
||||
link: "ss://" + modernUser + "@1.2.3.4:8388/?plugin=obfs-local%3Bobfs%3Dhttp#node",
|
||||
host: "1.2.3.4",
|
||||
port: 8388,
|
||||
method: "aes-256-gcm",
|
||||
pass: "secretpass",
|
||||
},
|
||||
{
|
||||
name: "legacy",
|
||||
link: "ss://" + legacyBody + "#node",
|
||||
host: "1.2.3.4",
|
||||
port: 8388,
|
||||
method: "aes-256-gcm",
|
||||
pass: "secretpass",
|
||||
},
|
||||
{
|
||||
name: "base64url userinfo with plugin and trailing slash",
|
||||
link: "ss://" + base64.RawURLEncoding.EncodeToString([]byte("aes-128-gcm:pa+ss/word")) + "@1.2.3.4:8388/?plugin=obfs-local%3Bobfs%3Dhttp#node",
|
||||
host: "1.2.3.4",
|
||||
port: 8388,
|
||||
method: "aes-128-gcm",
|
||||
pass: "pa+ss/word",
|
||||
},
|
||||
{
|
||||
name: "sip022 percent-encoded userinfo",
|
||||
link: "ss://2022-blake3-aes-256-gcm:YctPZ6U7xPPcU%2Bgp3u%2B0tx%2FtRizJN9K8y%2BuKlW2qjlI%3D@example.com:8888#Example3",
|
||||
host: "example.com",
|
||||
port: 8888,
|
||||
method: "2022-blake3-aes-256-gcm",
|
||||
pass: "YctPZ6U7xPPcU+gp3u+0tx/tRizJN9K8y+uKlW2qjlI=",
|
||||
},
|
||||
{
|
||||
name: "sip022 dual-key password with type query preserves inner colon",
|
||||
link: "ss://2022-blake3-aes-256-gcm:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA%3D:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB%3D@1.2.3.4:9999?type=tcp#node",
|
||||
host: "1.2.3.4",
|
||||
port: 9999,
|
||||
method: "2022-blake3-aes-256-gcm",
|
||||
pass: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
res, err := ParseLink(c.link)
|
||||
if err != nil {
|
||||
t.Fatalf("parse ss: %v", err)
|
||||
}
|
||||
if res.Outbound["protocol"] != "shadowsocks" {
|
||||
t.Fatalf("protocol = %v, want shadowsocks", res.Outbound["protocol"])
|
||||
}
|
||||
srv := res.Outbound["settings"].(map[string]any)["servers"].([]any)[0].(map[string]any)
|
||||
if srv["address"] != c.host {
|
||||
t.Errorf("address = %v, want %v", srv["address"], c.host)
|
||||
}
|
||||
if srv["port"] != c.port {
|
||||
t.Errorf("port = %v, want %v", srv["port"], c.port)
|
||||
}
|
||||
if srv["method"] != c.method {
|
||||
t.Errorf("method = %v, want %v", srv["method"], c.method)
|
||||
}
|
||||
if srv["password"] != c.pass {
|
||||
t.Errorf("password = %v, want %v", srv["password"], c.pass)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseShadowsocksBadPort(t *testing.T) {
|
||||
user := base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:secretpass"))
|
||||
cases := map[string]string{
|
||||
"modern": "ss://" + user + "@1.2.3.4:notaport#node",
|
||||
"legacy": "ss://" + base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:secretpass@1.2.3.4:notaport")) + "#node",
|
||||
}
|
||||
for name, link := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := ParseLink(link); err == nil {
|
||||
t.Errorf("expected parse error for non-numeric port, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSubscriptionBody_Base64(t *testing.T) {
|
||||
// base64 of the two joined links:
|
||||
// vless://u@h:443?type=tcp#A\nvless://u2@h2:443?type=tcp#B
|
||||
|
||||
@@ -110,7 +110,7 @@ func serveDistPage(c *gin.Context, name string) {
|
||||
if nonce := c.GetString("csp_nonce"); nonce != "" {
|
||||
nonceAttr = ` nonce="` + htmlpkg.EscapeString(nonce) + `"`
|
||||
}
|
||||
script := `<script` + nonceAttr + `>window.X_UI_BASE_PATH="` + escapedBase + `"`
|
||||
script := `<script data-cfasync="false"` + nonceAttr + `>window.X_UI_BASE_PATH="` + escapedBase + `"`
|
||||
if name != "login.html" {
|
||||
escapedVer := jsEscape.Replace(config.GetPanelVersion())
|
||||
script += `;window.X_UI_CUR_VER="` + escapedVer + `"`
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
)
|
||||
|
||||
func banLineCount(t *testing.T, email string) int {
|
||||
t.Helper()
|
||||
body, err := os.ReadFile(readIpLimitLogPath())
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0
|
||||
}
|
||||
t.Fatalf("read 3xipl.log: %v", err)
|
||||
}
|
||||
return strings.Count(string(body), "[LIMIT_IP] Email = "+email)
|
||||
}
|
||||
|
||||
func TestUpdateInboundClientIps_FrozenLastSeenBannedOnce(t *testing.T) {
|
||||
setupIntegrationDB(t)
|
||||
|
||||
const email = "frozen-ban"
|
||||
seedInboundWithClient(t, "inbound-frozen-ban", email, 1)
|
||||
|
||||
now := time.Now().Unix()
|
||||
deadStart := now - 300
|
||||
live := []IPWithTimestamp{
|
||||
{IP: "10.2.0.1", Timestamp: deadStart},
|
||||
{IP: "192.0.2.7", Timestamp: now},
|
||||
}
|
||||
|
||||
j := NewCheckClientIpJob()
|
||||
inbound, err := j.getInboundByEmail(email)
|
||||
if err != nil {
|
||||
t.Fatalf("getInboundByEmail: %v", err)
|
||||
}
|
||||
row := seedClientIps(t, email, nil)
|
||||
|
||||
if _, banned := j.updateInboundClientIps(database.GetDB(), row, inbound, email, 1, live, true, true); !banned {
|
||||
t.Fatalf("first scan: the over-limit stale IP must be banned")
|
||||
}
|
||||
if got := banLineCount(t, email); got != 1 {
|
||||
t.Fatalf("ban lines after first scan = %d, want 1", got)
|
||||
}
|
||||
|
||||
if _, banned := j.updateInboundClientIps(database.GetDB(), row, inbound, email, 1, live, true, true); banned {
|
||||
t.Fatalf("second scan with a frozen lastSeen must not re-ban a dead connection")
|
||||
}
|
||||
if got := banLineCount(t, email); got != 1 {
|
||||
t.Fatalf("ban lines after frozen rescan = %d, want still 1", got)
|
||||
}
|
||||
|
||||
reconnected := []IPWithTimestamp{
|
||||
{IP: "10.2.0.1", Timestamp: now + 30},
|
||||
{IP: "192.0.2.7", Timestamp: now + 60},
|
||||
}
|
||||
if _, banned := j.updateInboundClientIps(database.GetDB(), row, inbound, email, 1, reconnected, true, true); !banned {
|
||||
t.Fatalf("a reconnect (advanced lastSeen) must be banned again")
|
||||
}
|
||||
if got := banLineCount(t, email); got != 2 {
|
||||
t.Fatalf("ban lines after reconnect = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
@@ -32,6 +33,7 @@ type IPWithTimestamp struct {
|
||||
// simply skips the run (the bundled core always supports it).
|
||||
type CheckClientIpJob struct {
|
||||
disAllowedIps []string
|
||||
bannedSeen map[string]int64
|
||||
xrayService service.XrayService
|
||||
}
|
||||
|
||||
@@ -509,7 +511,8 @@ func (j *CheckClientIpJob) updateInboundClientIps(tx *gorm.DB, inboundClientIps
|
||||
|
||||
// historical db-only ips are excluded from this count on purpose.
|
||||
keptLive, bannedLive := selectIpsToBan(liveIps, limitIp)
|
||||
if len(bannedLive) > 0 {
|
||||
actionable := j.filterAdvancedSinceLastBan(clientEmail, bannedLive)
|
||||
if len(actionable) > 0 {
|
||||
shouldCleanLog = true
|
||||
banned = true
|
||||
|
||||
@@ -525,7 +528,7 @@ func (j *CheckClientIpJob) updateInboundClientIps(tx *gorm.DB, inboundClientIps
|
||||
// filter.d/3x-ipl.conf with
|
||||
// failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnecting OLD IP\s*=\s*<ADDR>\s*\|\|\s*Timestamp\s*=\s*\d+
|
||||
// don't change the wording.
|
||||
for _, ipTime := range bannedLive {
|
||||
for _, ipTime := range actionable {
|
||||
j.disAllowedIps = append(j.disAllowedIps, ipTime.IP)
|
||||
ipLogger.Printf("[LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d", clientEmail, ipTime.IP, ipTime.Timestamp)
|
||||
}
|
||||
@@ -552,6 +555,35 @@ func (j *CheckClientIpJob) updateInboundClientIps(tx *gorm.DB, inboundClientIps
|
||||
return shouldCleanLog, banned
|
||||
}
|
||||
|
||||
// filterAdvancedSinceLastBan keeps only banned pairs whose lastSeen advanced since
|
||||
// the previous ban: the core refreshes lastSeen solely on a new dispatch, so a
|
||||
// frozen value is a dead connection it hasn't reaped yet, not a reconnect.
|
||||
func (j *CheckClientIpJob) filterAdvancedSinceLastBan(email string, banned []IPWithTimestamp) []IPWithTimestamp {
|
||||
if j.bannedSeen == nil {
|
||||
j.bannedSeen = make(map[string]int64)
|
||||
}
|
||||
current := make(map[string]struct{}, len(banned))
|
||||
actionable := make([]IPWithTimestamp, 0, len(banned))
|
||||
for _, ipTime := range banned {
|
||||
key := email + "|" + ipTime.IP
|
||||
current[key] = struct{}{}
|
||||
if last, ok := j.bannedSeen[key]; ok && ipTime.Timestamp <= last {
|
||||
continue
|
||||
}
|
||||
j.bannedSeen[key] = ipTime.Timestamp
|
||||
actionable = append(actionable, ipTime)
|
||||
}
|
||||
prefix := email + "|"
|
||||
for key := range j.bannedSeen {
|
||||
if strings.HasPrefix(key, prefix) {
|
||||
if _, still := current[key]; !still {
|
||||
delete(j.bannedSeen, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return actionable
|
||||
}
|
||||
|
||||
// disconnectClientTemporarily removes and re-adds a client to force disconnect banned connections
|
||||
func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, clientEmail string, clients []model.Client) {
|
||||
var xrayAPI xray.XrayAPI
|
||||
|
||||
@@ -391,6 +391,20 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy
|
||||
}
|
||||
service.FilterNodeSnapshot(n, snap)
|
||||
_, _, dirty, _, _ := j.nodeService.NodeSyncState(n.Id)
|
||||
if !dirty {
|
||||
if pending, checkErr := j.inboundService.SnapshotHasUnadoptedInbounds(n.Id, snap); checkErr != nil {
|
||||
logger.Warningf("node traffic sync: unadopted-inbound check for %s failed: %v", n.Name, checkErr)
|
||||
} else if pending {
|
||||
hostCtx, hostCancel := context.WithTimeout(context.Background(), nodeTrafficSyncRequestTimeout)
|
||||
groups, hgErr := rt.FetchHostGroups(hostCtx)
|
||||
hostCancel()
|
||||
if hgErr != nil {
|
||||
logger.Debugf("node traffic sync: fetch host groups from %s failed: %v", n.Name, hgErr)
|
||||
} else {
|
||||
snap.HostGroups = groups
|
||||
}
|
||||
}
|
||||
}
|
||||
changed, err := j.inboundService.SetRemoteTraffic(n.Id, snap, dirty)
|
||||
if err != nil {
|
||||
logger.Warningf("node traffic sync: merge for %s failed: %v", n.Name, err)
|
||||
@@ -399,6 +413,11 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy
|
||||
if changed {
|
||||
j.structural.set()
|
||||
}
|
||||
if !dirty && n.InboundsAdoptedAt == 0 {
|
||||
if markErr := j.nodeService.MarkNodeInboundsAdopted(n.Id); markErr != nil {
|
||||
logger.Warningf("node traffic sync: mark inbounds adopted for %s failed: %v", n.Name, markErr)
|
||||
}
|
||||
}
|
||||
|
||||
active := make([]string, 0, len(snap.OnlineEmails))
|
||||
active = append(active, snap.OnlineEmails...)
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/wirecodec"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
@@ -669,6 +670,25 @@ type TrafficSnapshot struct {
|
||||
// the per-GUID endpoint — OnlineEmails is the fallback then.
|
||||
OnlineTree map[string][]string
|
||||
LastOnlineMap map[string]int64
|
||||
// HostGroups carries the node's per-inbound host overrides (TLS/SNI/
|
||||
// fingerprint), fetched only when the snapshot holds a not-yet-adopted tag.
|
||||
HostGroups []*entity.HostGroup
|
||||
}
|
||||
|
||||
// FetchHostGroups pulls the node's host overrides so a freshly adopted inbound
|
||||
// keeps its subscription TLS/SNI/fingerprint settings on the master.
|
||||
func (r *Remote) FetchHostGroups(ctx context.Context) ([]*entity.HostGroup, error) {
|
||||
env, err := r.do(ctx, http.MethodGet, "panel/api/hosts/list", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var groups []*entity.HostGroup
|
||||
if len(env.Obj) > 0 {
|
||||
if err := json.Unmarshal(env.Obj, &groups); err != nil {
|
||||
return nil, fmt.Errorf("decode host groups: %w", err)
|
||||
}
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
func (r *Remote) FetchTrafficSnapshot(ctx context.Context) (*TrafficSnapshot, error) {
|
||||
|
||||
@@ -1208,7 +1208,7 @@ func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []Client
|
||||
|
||||
db := database.GetDB()
|
||||
const lookupChunk = 400
|
||||
existingEmailSub := make(map[string]string, len(emails))
|
||||
existingByEmail := make(map[string]model.ClientRecord, len(emails))
|
||||
for start := 0; start < len(emails); start += lookupChunk {
|
||||
end := min(start+lookupChunk, len(emails))
|
||||
var rows []model.ClientRecord
|
||||
@@ -1216,7 +1216,7 @@ func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []Client
|
||||
return result, false, e
|
||||
}
|
||||
for i := range rows {
|
||||
existingEmailSub[strings.ToLower(rows[i].Email)] = rows[i].SubID
|
||||
existingByEmail[strings.ToLower(rows[i].Email)] = rows[i]
|
||||
}
|
||||
}
|
||||
existingSubOwner := make(map[string]string, len(subIDs))
|
||||
@@ -1252,10 +1252,24 @@ func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []Client
|
||||
|
||||
for idx := range prep {
|
||||
le := strings.ToLower(prep[idx].client.Email)
|
||||
if existSub, ok := existingEmailSub[le]; ok && existSub != prep[idx].client.SubID {
|
||||
failed[idx] = true
|
||||
reason[idx] = "email already in use: " + prep[idx].client.Email
|
||||
continue
|
||||
if rec, ok := existingByEmail[le]; ok {
|
||||
if rec.SubID != prep[idx].client.SubID {
|
||||
failed[idx] = true
|
||||
reason[idx] = "email already in use: " + prep[idx].client.Email
|
||||
continue
|
||||
}
|
||||
if prep[idx].client.ID == "" {
|
||||
prep[idx].client.ID = rec.UUID
|
||||
}
|
||||
if prep[idx].client.Password == "" {
|
||||
prep[idx].client.Password = rec.Password
|
||||
}
|
||||
if prep[idx].client.Auth == "" {
|
||||
prep[idx].client.Auth = rec.Auth
|
||||
}
|
||||
if prep[idx].client.Secret == "" {
|
||||
prep[idx].client.Secret = rec.Secret
|
||||
}
|
||||
}
|
||||
if owner, ok := existingSubOwner[prep[idx].client.SubID]; ok && owner != le {
|
||||
failed[idx] = true
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func settingsHoldUUID(t *testing.T, inboundSvc *InboundService, inboundId int, uuid string) bool {
|
||||
t.Helper()
|
||||
ib, err := inboundSvc.GetInbound(inboundId)
|
||||
if err != nil {
|
||||
t.Fatalf("GetInbound %d: %v", inboundId, err)
|
||||
}
|
||||
return strings.Contains(ib.Settings, uuid)
|
||||
}
|
||||
|
||||
func TestCreateRepeatKeepsExistingUUID(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
ibA := mkInbound(t, 21001, model.VLESS, `{"clients":[]}`)
|
||||
ibB := mkInbound(t, 21002, model.VLESS, `{"clients":[]}`)
|
||||
|
||||
const originalUUID = "aaaaaaaa-1111-2222-3333-444444444444"
|
||||
if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
|
||||
Client: model.Client{Email: "repeat@x", ID: originalUUID, SubID: "sub-repeat", Enable: true},
|
||||
InboundIds: []int{ibA.Id},
|
||||
}); err != nil {
|
||||
t.Fatalf("first Create: %v", err)
|
||||
}
|
||||
if rec := lookupClientRecord(t, "repeat@x"); rec.UUID != originalUUID {
|
||||
t.Fatalf("record UUID after first Create = %q, want %q", rec.UUID, originalUUID)
|
||||
}
|
||||
|
||||
if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
|
||||
Client: model.Client{Email: "repeat@x", SubID: "sub-repeat", Enable: true},
|
||||
InboundIds: []int{ibB.Id},
|
||||
}); err != nil {
|
||||
t.Fatalf("repeat Create: %v", err)
|
||||
}
|
||||
|
||||
if rec := lookupClientRecord(t, "repeat@x"); rec.UUID != originalUUID {
|
||||
t.Fatalf("record UUID after repeat Create = %q, want %q", rec.UUID, originalUUID)
|
||||
}
|
||||
if !settingsHoldUUID(t, inboundSvc, ibA.Id, originalUUID) {
|
||||
t.Fatalf("inbound A settings lost the original UUID")
|
||||
}
|
||||
if !settingsHoldUUID(t, inboundSvc, ibB.Id, originalUUID) {
|
||||
t.Fatalf("inbound B settings did not reuse the original UUID")
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,20 @@ func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreate
|
||||
if existing.SubID == "" || existing.SubID != client.SubID {
|
||||
return false, common.NewError("email already in use:", client.Email)
|
||||
}
|
||||
// Reuse stored credentials when re-adding an existing identity, or
|
||||
// fillProtocolDefaults mints a fresh UUID that desyncs other inbounds.
|
||||
if client.ID == "" {
|
||||
client.ID = existing.UUID
|
||||
}
|
||||
if client.Password == "" {
|
||||
client.Password = existing.Password
|
||||
}
|
||||
if client.Auth == "" {
|
||||
client.Auth = existing.Auth
|
||||
}
|
||||
if client.Secret == "" {
|
||||
client.Secret = existing.Secret
|
||||
}
|
||||
}
|
||||
|
||||
if client.SubID != "" {
|
||||
@@ -345,6 +359,9 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
|
||||
if updated.Auth == "" {
|
||||
updated.Auth = existing.Auth
|
||||
}
|
||||
if updated.Secret == "" {
|
||||
updated.Secret = existing.Secret
|
||||
}
|
||||
|
||||
if updated.Email != existing.Email {
|
||||
var collisionCount int64
|
||||
@@ -356,11 +373,6 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
|
||||
if collisionCount > 0 {
|
||||
return false, common.NewError("Duplicate email:", updated.Email)
|
||||
}
|
||||
if err := database.GetDB().Model(&model.ClientRecord{}).
|
||||
Where("id = ?", id).
|
||||
Update("email", updated.Email).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
if updated.SubID != "" {
|
||||
@@ -411,6 +423,16 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateInboundClient renames the record atomically with each inbound's
|
||||
// settings JSON; this direct write only covers records with no inbound left.
|
||||
if updated.Email != existing.Email {
|
||||
if err := database.GetDB().Model(&model.ClientRecord{}).
|
||||
Where("id = ? AND email = ?", id, existing.Email).
|
||||
Update("email", updated.Email).Error; err != nil {
|
||||
return needRestart, err
|
||||
}
|
||||
}
|
||||
|
||||
reverseStr := ""
|
||||
if updated.Reverse != nil && strings.TrimSpace(updated.Reverse.Tag) != "" {
|
||||
if b, mErr := json.Marshal(updated.Reverse); mErr == nil {
|
||||
@@ -471,12 +493,14 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
|
||||
}
|
||||
|
||||
needRestart := false
|
||||
var delErrs []error
|
||||
for _, ibId := range inboundIds {
|
||||
if _, getErr := inboundSvc.GetInbound(ibId); getErr != nil {
|
||||
if errors.Is(getErr, gorm.ErrRecordNotFound) {
|
||||
continue
|
||||
}
|
||||
return needRestart, getErr
|
||||
delErrs = append(delErrs, fmt.Errorf("inbound %d: %w", ibId, getErr))
|
||||
continue
|
||||
}
|
||||
|
||||
// Always delete by email — the client's stable identity. This removes
|
||||
@@ -493,12 +517,18 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
|
||||
if errors.Is(delErr, ErrClientNotInInbound) {
|
||||
continue
|
||||
}
|
||||
return needRestart, delErr
|
||||
delErrs = append(delErrs, fmt.Errorf("inbound %d: %w", ibId, delErr))
|
||||
continue
|
||||
}
|
||||
if nr {
|
||||
needRestart = true
|
||||
}
|
||||
}
|
||||
// A failed inbound still holds the client in its settings JSON: keep the
|
||||
// record so the next delete retries exactly the leftovers, and report it.
|
||||
if len(delErrs) > 0 {
|
||||
return needRestart, errors.Join(delErrs...)
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
@@ -646,18 +676,23 @@ func (s *ClientService) DeleteByEmail(inboundSvc *InboundService, email string,
|
||||
return false, common.NewError(fmt.Sprintf("client %q not found in any inbound or client record", email))
|
||||
}
|
||||
needRestart := false
|
||||
var delErrs []error
|
||||
for _, ibId := range inboundIds {
|
||||
nr, delErr := s.DelInboundClientByEmail(inboundSvc, ibId, email, false, true)
|
||||
if delErr != nil {
|
||||
if errors.Is(delErr, ErrClientNotInInbound) {
|
||||
continue
|
||||
}
|
||||
return needRestart, delErr
|
||||
delErrs = append(delErrs, fmt.Errorf("inbound %d: %w", ibId, delErr))
|
||||
continue
|
||||
}
|
||||
if nr {
|
||||
needRestart = true
|
||||
}
|
||||
}
|
||||
if len(delErrs) > 0 {
|
||||
return needRestart, errors.Join(delErrs...)
|
||||
}
|
||||
if !keepTraffic {
|
||||
db := database.GetDB()
|
||||
if err := db.Where("email = ?", email).Delete(&xray.ClientTraffic{}).Error; err != nil {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func TestDeleteContinuesPastFailedInbound(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
source := []model.Client{{Email: "spread@x", ID: "cccccccc-0000-0000-0000-000000000001", SubID: "sub-spread", Enable: true}}
|
||||
ib1 := mkInbound(t, 23001, model.VLESS, clientsSettings(t, source))
|
||||
ib2 := mkInbound(t, 23002, model.VLESS, clientsSettings(t, source))
|
||||
ib3 := mkInbound(t, 23003, model.VLESS, clientsSettings(t, source))
|
||||
for _, ib := range []*model.Inbound{ib1, ib2, ib3} {
|
||||
if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
|
||||
t.Fatalf("seed linkage for %d: %v", ib.Id, err)
|
||||
}
|
||||
}
|
||||
rec := lookupClientRecord(t, "spread@x")
|
||||
|
||||
missingNode := 9999
|
||||
if err := database.GetDB().Model(&model.Inbound{}).Where("id = ?", ib2.Id).
|
||||
Update("node_id", missingNode).Error; err != nil {
|
||||
t.Fatalf("point inbound 2 at a missing node: %v", err)
|
||||
}
|
||||
|
||||
_, err := svc.Delete(inboundSvc, rec.Id, false)
|
||||
if err == nil {
|
||||
t.Fatalf("Delete with a failing inbound succeeded, want error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "inbound "+strconv.Itoa(ib2.Id)) {
|
||||
t.Fatalf("Delete error = %q, want it to name inbound %d", err, ib2.Id)
|
||||
}
|
||||
|
||||
for _, ib := range []*model.Inbound{ib1, ib3} {
|
||||
if settingsHoldUUID(t, inboundSvc, ib.Id, "spread@x") {
|
||||
t.Fatalf("inbound %d still holds the client after Delete", ib.Id)
|
||||
}
|
||||
}
|
||||
if _, err := svc.GetByID(rec.Id); err != nil {
|
||||
t.Fatalf("record removed despite a failed inbound: %v", err)
|
||||
}
|
||||
|
||||
if err := database.GetDB().Model(&model.Inbound{}).Where("id = ?", ib2.Id).
|
||||
Update("node_id", nil).Error; err != nil {
|
||||
t.Fatalf("repair inbound 2: %v", err)
|
||||
}
|
||||
if _, err := svc.Delete(inboundSvc, rec.Id, false); err != nil {
|
||||
t.Fatalf("retry Delete: %v", err)
|
||||
}
|
||||
if _, err := svc.GetByID(rec.Id); err == nil {
|
||||
t.Fatalf("record still present after successful retry")
|
||||
}
|
||||
if settingsHoldUUID(t, inboundSvc, ib2.Id, "spread@x") {
|
||||
t.Fatalf("inbound 2 still holds the client after retry")
|
||||
}
|
||||
}
|
||||
@@ -805,6 +805,19 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
|
||||
if e := tx.Save(oldInbound).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
// Rename the client record in the same transaction as the settings JSON
|
||||
// so no concurrent SyncInbound can see one renamed without the other.
|
||||
if len(oldEmail) > 0 && !strings.EqualFold(oldEmail, clients[0].Email) {
|
||||
var renameTaken int64
|
||||
if e := tx.Model(&model.ClientRecord{}).Where("email = ?", clients[0].Email).Count(&renameTaken).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
if renameTaken == 0 {
|
||||
if e := tx.Model(&model.ClientRecord{}).Where("email = ?", oldEmail).Update("email", clients[0].Email).Error; e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
}
|
||||
finalClients, gcErr := inboundSvc.GetClients(oldInbound)
|
||||
if gcErr != nil {
|
||||
return gcErr
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func countClientRecords(t *testing.T) int64 {
|
||||
t.Helper()
|
||||
var n int64
|
||||
if err := database.GetDB().Model(&model.ClientRecord{}).Count(&n).Error; err != nil {
|
||||
t.Fatalf("count client records: %v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestUpdateInboundClientRenameDoesNotDuplicateRecord(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
source := []model.Client{{Email: "old@x", ID: "aaaaaaaa-0000-0000-0000-000000000001", SubID: "sub-old", Enable: true}}
|
||||
ib := mkInbound(t, 22001, model.VLESS, clientsSettings(t, source))
|
||||
if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
|
||||
t.Fatalf("seed linkage: %v", err)
|
||||
}
|
||||
origId := lookupClientRecord(t, "old@x").Id
|
||||
|
||||
renamed := source
|
||||
renamed[0].Email = "new@x"
|
||||
if _, err := svc.UpdateInboundClient(inboundSvc, &model.Inbound{
|
||||
Id: ib.Id,
|
||||
Settings: clientsSettings(t, renamed),
|
||||
}, "old@x"); err != nil {
|
||||
t.Fatalf("UpdateInboundClient: %v", err)
|
||||
}
|
||||
|
||||
if n := countClientRecords(t); n != 1 {
|
||||
t.Fatalf("client records after rename = %d, want 1", n)
|
||||
}
|
||||
rec := lookupClientRecord(t, "new@x")
|
||||
if rec.Id != origId {
|
||||
t.Fatalf("record id after rename = %d, want %d", rec.Id, origId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientUpdateDuplicateSubIDDoesNotRenameEmail(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
source := []model.Client{
|
||||
{Email: "keep@x", ID: "aaaaaaaa-0000-0000-0000-000000000003", SubID: "sub-keep", Enable: true},
|
||||
{Email: "other@x", ID: "aaaaaaaa-0000-0000-0000-000000000004", SubID: "sub-other", Enable: true},
|
||||
}
|
||||
ib := mkInbound(t, 22003, model.VLESS, clientsSettings(t, source))
|
||||
if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
|
||||
t.Fatalf("seed linkage: %v", err)
|
||||
}
|
||||
origId := lookupClientRecord(t, "keep@x").Id
|
||||
origSettings := mustInboundSettings(t, inboundSvc, ib.Id)
|
||||
|
||||
updated := source[0]
|
||||
updated.Email = "kept@x"
|
||||
updated.SubID = "sub-other"
|
||||
if _, err := svc.Update(inboundSvc, origId, updated); err == nil {
|
||||
t.Fatalf("Update with colliding subId succeeded, want error")
|
||||
}
|
||||
|
||||
rec := lookupClientRecord(t, "keep@x")
|
||||
if rec.Id != origId {
|
||||
t.Fatalf("record id changed after rejected update")
|
||||
}
|
||||
if got := mustInboundSettings(t, inboundSvc, ib.Id); got != origSettings {
|
||||
t.Fatalf("inbound settings changed after rejected update")
|
||||
}
|
||||
}
|
||||
|
||||
func mustInboundSettings(t *testing.T, inboundSvc *InboundService, id int) string {
|
||||
t.Helper()
|
||||
ib, err := inboundSvc.GetInbound(id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetInbound %d: %v", id, err)
|
||||
}
|
||||
return ib.Settings
|
||||
}
|
||||
@@ -154,6 +154,21 @@ func buildHostRows(groupId string, req *entity.HostGroup) []*model.Host {
|
||||
return rows
|
||||
}
|
||||
|
||||
// adoptedHostRows projects a node's host groups onto a freshly adopted central
|
||||
// inbound so TLS/SNI/fingerprint overrides survive the node-to-master import.
|
||||
func adoptedHostRows(groups []*entity.HostGroup, nodeInboundId, centralInboundId int) []*model.Host {
|
||||
var rows []*model.Host
|
||||
for _, g := range groups {
|
||||
if g == nil || !slices.Contains(g.InboundIds, nodeInboundId) {
|
||||
continue
|
||||
}
|
||||
scoped := *g
|
||||
scoped.InboundIds = []int{centralInboundId}
|
||||
rows = append(rows, buildHostRows(g.GroupId, &scoped)...)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func validateInboundsExist(tx *gorm.DB, inboundIds []int) error {
|
||||
for _, inboundId := range inboundIds {
|
||||
var count int64
|
||||
|
||||
@@ -503,7 +503,9 @@ func (s *InboundService) getAllEmailSubIDs() (map[string]string, error) {
|
||||
// Only vmess, vless, trojan, shadowsocks, hysteria, wireguard, and tunnel
|
||||
// protocols use streamSettings (wireguard for finalmask UDP masks and sockopt on
|
||||
// its listener; tunnel for sockopt, notably sockopt.tproxy for its TProxy/redirect
|
||||
// mode).
|
||||
// mode). Streams keyed on "method" — xray-core v26.7.11's preferred alias for
|
||||
// "network" — are canonicalized to "network", which every panel reader (link
|
||||
// generation, port-conflict detection, flow eligibility) keys on.
|
||||
func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) {
|
||||
protocolsWithStream := map[model.Protocol]bool{
|
||||
model.VMESS: true,
|
||||
@@ -517,7 +519,33 @@ func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) {
|
||||
|
||||
if !protocolsWithStream[inbound.Protocol] {
|
||||
inbound.StreamSettings = ""
|
||||
return
|
||||
}
|
||||
inbound.StreamSettings = canonicalizeStreamNetworkKey(inbound.StreamSettings)
|
||||
}
|
||||
|
||||
// canonicalizeStreamNetworkKey rewrites a streamSettings JSON that names its
|
||||
// transport under "method" to the panel-canonical "network" key. When both
|
||||
// keys are present, "method" wins — matching xray-core's own precedence.
|
||||
func canonicalizeStreamNetworkKey(streamSettings string) string {
|
||||
if streamSettings == "" {
|
||||
return streamSettings
|
||||
}
|
||||
var stream map[string]any
|
||||
if err := json.Unmarshal([]byte(streamSettings), &stream); err != nil {
|
||||
return streamSettings
|
||||
}
|
||||
method, ok := stream["method"].(string)
|
||||
if !ok || method == "" {
|
||||
return streamSettings
|
||||
}
|
||||
stream["network"] = method
|
||||
delete(stream, "method")
|
||||
out, err := json.MarshalIndent(stream, "", " ")
|
||||
if err != nil {
|
||||
return streamSettings
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// finalMaskRealityTcpMasks returns the stream's finalmask.tcp masks when the
|
||||
|
||||
@@ -129,6 +129,11 @@ func (s *InboundService) ReconcileNode(ctx context.Context, rt *runtime.Remote,
|
||||
errs = append(errs, fmt.Errorf("reconcile inbound %q: %w", ib.Tag, err))
|
||||
}
|
||||
}
|
||||
// Before the first clean sync adopts the node's inbounds, "absent locally"
|
||||
// means "not imported yet" — sweeping now would wipe the node at onboarding.
|
||||
if n.InboundsAdoptedAt == 0 {
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
// In "selected" sync mode the panel only manages the selected tags: the
|
||||
// rest were never imported, so their absence from the local DB must not
|
||||
// delete them from the node. Only a selected tag missing locally (the
|
||||
@@ -219,6 +224,41 @@ func liftActivatedClientRecordExpiries(tx *gorm.DB) error {
|
||||
).Error
|
||||
}
|
||||
|
||||
// SnapshotHasUnadoptedInbounds reports whether the snapshot carries a tag with
|
||||
// no central row yet, i.e. the next merge would adopt a new inbound.
|
||||
func (s *InboundService) SnapshotHasUnadoptedInbounds(nodeID int, snap *runtime.TrafficSnapshot) (bool, error) {
|
||||
if snap == nil || len(snap.Inbounds) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
var tags []string
|
||||
if err := database.GetDB().Model(model.Inbound{}).
|
||||
Where("node_id = ?", nodeID).
|
||||
Pluck("tag", &tags).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
prefix := nodeTagPrefix(&nodeID)
|
||||
known := make(map[string]struct{}, len(tags)*2)
|
||||
for _, tag := range tags {
|
||||
known[tag] = struct{}{}
|
||||
if prefix != "" {
|
||||
if stripped, found := strings.CutPrefix(tag, prefix); found {
|
||||
known[stripped] = struct{}{}
|
||||
} else {
|
||||
known[prefix+tag] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, ib := range snap.Inbounds {
|
||||
if ib == nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := known[ib.Tag]; !ok {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnapshot, dirty bool) (bool, error) {
|
||||
var structuralChange bool
|
||||
err := submitTrafficWrite(func() error {
|
||||
@@ -532,6 +572,11 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||
if newIb.Tag != snapIb.Tag {
|
||||
tagToCentral[newIb.Tag] = &newIb
|
||||
}
|
||||
if rows := adoptedHostRows(snap.HostGroups, snapIb.Id, newIb.Id); len(rows) > 0 {
|
||||
if err := tx.Create(&rows).Error; err != nil {
|
||||
logger.Warningf("setRemoteTraffic: adopt host rows for tag %q failed: %v", newIb.Tag, err)
|
||||
}
|
||||
}
|
||||
newInboundIDs[newIb.Id] = struct{}{}
|
||||
structuralChange = true
|
||||
continue
|
||||
|
||||
@@ -87,6 +87,7 @@ func reconcileTestNode(t *testing.T, ts *httptest.Server, name, mode string, tag
|
||||
Status: "online",
|
||||
InboundSyncMode: mode,
|
||||
InboundTags: tags,
|
||||
InboundsAdoptedAt: 1,
|
||||
}
|
||||
if err := database.GetDB().Create(n).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
@@ -143,6 +144,31 @@ func TestReconcileNode_AllModeDeletesUndesiredRemoteInbounds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A node whose pre-existing inbounds were never adopted into the central DB
|
||||
// has zero local rows for legitimate reasons: reconcile before that first
|
||||
// adoption must not sweep — it would delete every real inbound on the node
|
||||
// right after onboarding (add node, save it again, watch it get wiped).
|
||||
func TestReconcileNode_SkipsSweepBeforeFirstAdoption(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
|
||||
ts, deletedIDs := fakeNodePanel(t, map[string]int{
|
||||
"real-a": 1,
|
||||
"real-b": 2,
|
||||
"real-c": 3,
|
||||
})
|
||||
node := reconcileTestNode(t, ts, "fresh-node", "all", nil)
|
||||
node.InboundsAdoptedAt = 0
|
||||
|
||||
svc := InboundService{}
|
||||
if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node, nil), node); err != nil {
|
||||
t.Fatalf("ReconcileNode: %v", err)
|
||||
}
|
||||
|
||||
if got := deletedIDs(); len(got) != 0 {
|
||||
t.Fatalf("deleted remote ids = %v, want none before first adoption", got)
|
||||
}
|
||||
}
|
||||
|
||||
// One inbound the node rejects (e.g. a legacy protocol failing the node's
|
||||
// request validation, #5685) must not abort the reconcile: the healthy inbound
|
||||
// is still pushed, the delete sweep still runs, and the returned error names
|
||||
|
||||
@@ -771,6 +771,15 @@ func (s *NodeService) ClearNodeDirty(id int, dirtyAt int64) error {
|
||||
Update("config_dirty", false).Error
|
||||
}
|
||||
|
||||
func (s *NodeService) MarkNodeInboundsAdopted(id int) error {
|
||||
if id <= 0 {
|
||||
return nil
|
||||
}
|
||||
return database.GetDB().Model(model.Node{}).
|
||||
Where("id = ? AND inbounds_adopted_at = 0", id).
|
||||
Update("inbounds_adopted_at", time.Now().Unix()).Error
|
||||
}
|
||||
|
||||
func (s *NodeService) NodeSyncState(id int) (enabled bool, status string, dirty bool, dirtyAt int64, err error) {
|
||||
if id <= 0 {
|
||||
return false, "", false, 0, errors.New("invalid node id")
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
||||
)
|
||||
|
||||
func TestSetRemoteTraffic_AdoptsNodeHostRows(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
db := database.GetDB()
|
||||
|
||||
const nodeID = 6
|
||||
if err := db.Create(&model.Node{
|
||||
Id: nodeID,
|
||||
Name: "host-node",
|
||||
Address: "10.0.0.6",
|
||||
Port: 2053,
|
||||
ApiToken: "t",
|
||||
Guid: "host-node-guid",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
|
||||
snap := &runtime.TrafficSnapshot{
|
||||
Inbounds: []*model.Inbound{{
|
||||
Id: 77,
|
||||
Tag: "host-adopt-443",
|
||||
Enable: true,
|
||||
Port: 443,
|
||||
Protocol: model.VLESS,
|
||||
Settings: `{"clients":[]}`,
|
||||
}},
|
||||
HostGroups: []*entity.HostGroup{{
|
||||
GroupId: "g-node",
|
||||
InboundIds: []int{77, 99},
|
||||
Hosts: []string{"cdn.example.com:8443"},
|
||||
Remark: "cdn",
|
||||
Security: "tls",
|
||||
Sni: "sni.example.com",
|
||||
Fingerprint: "firefox",
|
||||
}},
|
||||
}
|
||||
|
||||
svc := InboundService{}
|
||||
if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false); err != nil {
|
||||
t.Fatalf("setRemoteTrafficLocked: %v", err)
|
||||
}
|
||||
|
||||
var central model.Inbound
|
||||
if err := db.Where("tag = ?", "host-adopt-443").First(¢ral).Error; err != nil {
|
||||
t.Fatalf("load adopted inbound: %v", err)
|
||||
}
|
||||
var hosts []model.Host
|
||||
if err := db.Where("inbound_id = ?", central.Id).Find(&hosts).Error; err != nil {
|
||||
t.Fatalf("load adopted hosts: %v", err)
|
||||
}
|
||||
if len(hosts) != 1 {
|
||||
t.Fatalf("adopted host rows = %d, want 1", len(hosts))
|
||||
}
|
||||
h := hosts[0]
|
||||
if h.GroupId != "g-node" || h.Address != "cdn.example.com" || h.Port != 8443 ||
|
||||
h.Security != "tls" || h.Sni != "sni.example.com" || h.Fingerprint != "firefox" || h.Remark != "cdn" {
|
||||
t.Fatalf("adopted host mismatch: %+v", h)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := db.Model(&model.Host{}).Count(&total).Error; err != nil {
|
||||
t.Fatalf("count hosts: %v", err)
|
||||
}
|
||||
if total != 1 {
|
||||
t.Fatalf("total host rows = %d, want 1 (group member for un-adopted node inbound 99 must not materialize)", total)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package outbound
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseCloudflareTrace(t *testing.T) {
|
||||
values := parseCloudflareTrace("ip=104.28.1.2\nloc=NL\nwarp=on\n")
|
||||
|
||||
if values["ip"] != "104.28.1.2" {
|
||||
t.Fatalf("ip = %q", values["ip"])
|
||||
}
|
||||
if values["loc"] != "NL" {
|
||||
t.Fatalf("loc = %q", values["loc"])
|
||||
}
|
||||
if values["warp"] != "on" {
|
||||
t.Fatalf("warp = %q", values["warp"])
|
||||
}
|
||||
}
|
||||
@@ -142,6 +142,7 @@ type TestOutboundResult struct {
|
||||
TTFBMs int64 `json:"ttfbMs,omitempty"`
|
||||
|
||||
Endpoints []TestEndpointResult `json:"endpoints,omitempty"`
|
||||
Egress *TestEgressResult `json:"egress,omitempty"`
|
||||
}
|
||||
|
||||
// TestEndpointResult is one entry in a TCP-mode probe — the per-endpoint
|
||||
@@ -153,6 +154,15 @@ type TestEndpointResult struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// TestEgressResult is populated by HTTP-mode probes from Cloudflare's trace
|
||||
// endpoint. It reports what an external service sees after the outbound chain.
|
||||
type TestEgressResult struct {
|
||||
IPv4 string `json:"ipv4,omitempty"`
|
||||
IPv6 string `json:"ipv6,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
Warp string `json:"warp,omitempty"`
|
||||
}
|
||||
|
||||
func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundResult, error) {
|
||||
var ob map[string]any
|
||||
if err := json.Unmarshal([]byte(outboundJSON), &ob); err != nil {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -54,8 +55,13 @@ const (
|
||||
// tcpBatchConcurrency caps parallel TCP-mode items in a batch (each item
|
||||
// already dials its endpoints concurrently).
|
||||
tcpBatchConcurrency = 8
|
||||
// egressTraceTimeout keeps diagnostic trace metadata from extending a
|
||||
// successful HTTP probe by the full reachability timeout.
|
||||
egressTraceTimeout = 3 * time.Second
|
||||
|
||||
defaultTestURL = "https://www.google.com/generate_204"
|
||||
defaultTestURL = "https://www.google.com/generate_204"
|
||||
egressTraceHost = "cloudflare.com"
|
||||
egressTracePath = "/cdn-cgi/trace"
|
||||
)
|
||||
|
||||
// httpTestSemaphore serialises HTTP-mode batches (each spawns a temp xray
|
||||
@@ -76,6 +82,8 @@ var newBatchProcess = func(cfg *xray.Config, configPath string) batchProcess {
|
||||
return xray.NewTestProcess(cfg, configPath)
|
||||
}
|
||||
|
||||
var egressTraceProbe = probeEgressTrace
|
||||
|
||||
// httpBatchItem is one outbound inside an HTTP-mode batch. result is the
|
||||
// pre-allocated entry in the caller's result slice, filled in place.
|
||||
type httpBatchItem struct {
|
||||
@@ -542,6 +550,150 @@ func probeThroughSocks(port int, testURL string, timeout time.Duration, realDela
|
||||
}
|
||||
}
|
||||
result.Delay = max(delay, 1)
|
||||
if !realDelay {
|
||||
result.Egress = egressTraceProbe(proxyURL)
|
||||
}
|
||||
}
|
||||
|
||||
// probeEgressTrace fetches Cloudflare's plain-text trace endpoint through the
|
||||
// same SOCKS route used by the HTTP probe. It asks one IPv4 and one IPv6
|
||||
// Cloudflare address directly, when available, while keeping the TLS SNI as
|
||||
// cloudflare.com. Failures are intentionally ignored by the caller: egress
|
||||
// metadata is diagnostic, not reachability.
|
||||
func probeEgressTrace(proxyURL *url.URL) *TestEgressResult {
|
||||
ipv4, ipv6 := cloudflareTraceTargets()
|
||||
if ipv4 == nil && ipv6 == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
tr := &http.Transport{
|
||||
Proxy: http.ProxyURL(proxyURL),
|
||||
TLSClientConfig: &tls.Config{
|
||||
ServerName: egressTraceHost,
|
||||
},
|
||||
}
|
||||
defer tr.CloseIdleConnections()
|
||||
|
||||
client := &http.Client{
|
||||
Transport: tr,
|
||||
Timeout: egressTraceTimeout,
|
||||
}
|
||||
|
||||
egress := &TestEgressResult{}
|
||||
targets := make([]net.IP, 0, 2)
|
||||
if ipv4 != nil {
|
||||
targets = append(targets, ipv4)
|
||||
}
|
||||
if ipv6 != nil {
|
||||
targets = append(targets, ipv6)
|
||||
}
|
||||
results := make(chan map[string]string, len(targets))
|
||||
for _, target := range targets {
|
||||
go func(ip net.IP) {
|
||||
results <- fetchCloudflareTrace(client, ip)
|
||||
}(target)
|
||||
}
|
||||
for range targets {
|
||||
applyEgressTrace(egress, <-results)
|
||||
}
|
||||
if egress.IPv4 == "" && egress.IPv6 == "" && egress.Country == "" && egress.Warp == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return egress
|
||||
}
|
||||
|
||||
func cloudflareTraceTargets() (net.IP, net.IP) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), egressTraceTimeout)
|
||||
defer cancel()
|
||||
|
||||
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, egressTraceHost)
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
var ipv4, ipv6 net.IP
|
||||
for _, addr := range addrs {
|
||||
ip := addr.IP
|
||||
if ipv4 == nil {
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
ipv4 = v4
|
||||
continue
|
||||
}
|
||||
}
|
||||
if ipv6 == nil && ip.To4() == nil && ip.To16() != nil {
|
||||
ipv6 = ip
|
||||
}
|
||||
if ipv4 != nil && ipv6 != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
return ipv4, ipv6
|
||||
}
|
||||
|
||||
func fetchCloudflareTrace(client *http.Client, ip net.IP) map[string]string {
|
||||
if ip == nil {
|
||||
return nil
|
||||
}
|
||||
traceURL := (&url.URL{
|
||||
Scheme: "https",
|
||||
Host: net.JoinHostPort(ip.String(), "443"),
|
||||
Path: egressTracePath,
|
||||
}).String()
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, traceURL, nil)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
req.Host = egressTraceHost
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 16<<10))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return parseCloudflareTrace(string(body))
|
||||
}
|
||||
|
||||
func applyEgressTrace(egress *TestEgressResult, values map[string]string) {
|
||||
if len(values) == 0 {
|
||||
return
|
||||
}
|
||||
if ip := net.ParseIP(values["ip"]); ip != nil {
|
||||
if ip.To4() != nil {
|
||||
if egress.IPv4 == "" {
|
||||
egress.IPv4 = values["ip"]
|
||||
}
|
||||
} else if egress.IPv6 == "" {
|
||||
egress.IPv6 = values["ip"]
|
||||
}
|
||||
}
|
||||
if egress.Country == "" {
|
||||
egress.Country = values["loc"]
|
||||
}
|
||||
if values["warp"] == "on" || egress.Warp == "" {
|
||||
egress.Warp = values["warp"]
|
||||
}
|
||||
}
|
||||
|
||||
func parseCloudflareTrace(body string) map[string]string {
|
||||
values := make(map[string]string)
|
||||
for line := range strings.SplitSeq(body, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
values[strings.TrimSpace(key)] = strings.TrimSpace(value)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
// timedWarmGet re-issues the probe request over the transport's kept-alive
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -135,6 +136,13 @@ func withStubProcess(t *testing.T, factory func(cfg *xray.Config, configPath str
|
||||
t.Cleanup(func() { newBatchProcess = orig })
|
||||
}
|
||||
|
||||
func withEgressTraceProbe(t *testing.T, probe func(*url.URL) *TestEgressResult) {
|
||||
t.Helper()
|
||||
orig := egressTraceProbe
|
||||
egressTraceProbe = probe
|
||||
t.Cleanup(func() { egressTraceProbe = orig })
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, v any) string {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
@@ -417,6 +425,9 @@ func TestTestOutboundsHTTPBatchThroughStubSocks(t *testing.T) {
|
||||
proc = &stubProcess{cfg: cfg, serveSocks: true}
|
||||
return proc
|
||||
})
|
||||
withEgressTraceProbe(t, func(*url.URL) *TestEgressResult {
|
||||
return &TestEgressResult{IPv4: "198.51.100.1", Country: "ZZ", Warp: "off"}
|
||||
})
|
||||
|
||||
batch := mustJSON(t, []any{
|
||||
map[string]any{"tag": "a", "protocol": "vless"},
|
||||
@@ -445,6 +456,9 @@ func TestTestOutboundsHTTPBatchThroughStubSocks(t *testing.T) {
|
||||
if r.Mode != "http" {
|
||||
t.Errorf("result %d mode = %q", i, r.Mode)
|
||||
}
|
||||
if r.Egress == nil || r.Egress.IPv4 != "198.51.100.1" {
|
||||
t.Errorf("result %d egress = %+v", i, r.Egress)
|
||||
}
|
||||
}
|
||||
if proc.IsRunning() {
|
||||
t.Error("temp process not stopped after batch")
|
||||
@@ -525,6 +539,9 @@ func TestTestOutboundsTCPModeForcesUDPToHTTPProbe(t *testing.T) {
|
||||
withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess {
|
||||
return &stubProcess{cfg: cfg, serveSocks: true}
|
||||
})
|
||||
withEgressTraceProbe(t, func(*url.URL) *TestEgressResult {
|
||||
return &TestEgressResult{IPv4: "198.51.100.2", Country: "ZZ", Warp: "off"}
|
||||
})
|
||||
|
||||
batch := mustJSON(t, []any{map[string]any{"tag": "wg", "protocol": "wireguard"}})
|
||||
results, err := (&OutboundService{}).TestOutbounds(batch, srv.URL, "", "tcp")
|
||||
@@ -535,6 +552,9 @@ func TestTestOutboundsTCPModeForcesUDPToHTTPProbe(t *testing.T) {
|
||||
if !r.Success || r.Mode != "http" {
|
||||
t.Errorf("UDP outbound in tcp mode = %+v, want success with mode %q", r, "http")
|
||||
}
|
||||
if r.Egress == nil || r.Egress.IPv4 != "198.51.100.2" {
|
||||
t.Errorf("UDP outbound egress = %+v", r.Egress)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeModeLabel(t *testing.T) {
|
||||
|
||||
@@ -17,8 +17,34 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/link"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
// filterOutboundsRejectedByCore drops outbounds the vendored xray-core config
|
||||
// loader refuses to build — since v26.7.11 that includes unencrypted
|
||||
// vless/trojan outbounds to public addresses — because one such outbound in
|
||||
// the merged config would keep the whole core from starting.
|
||||
func filterOutboundsRejectedByCore(label string, outbounds []any) ([]any, []string) {
|
||||
kept := make([]any, 0, len(outbounds))
|
||||
var dropped []string
|
||||
for _, ob := range outbounds {
|
||||
raw, err := json.Marshal(ob)
|
||||
if err == nil {
|
||||
if buildErr := xray.ValidateOutboundConfig(raw); buildErr != nil {
|
||||
tag := ""
|
||||
if m, ok := ob.(map[string]any); ok {
|
||||
tag, _ = m["tag"].(string)
|
||||
}
|
||||
logger.Warningf("%s: dropping outbound %q rejected by xray-core: %v", label, tag, buildErr)
|
||||
dropped = append(dropped, fmt.Sprintf("%s: %v", tag, buildErr))
|
||||
continue
|
||||
}
|
||||
}
|
||||
kept = append(kept, ob)
|
||||
}
|
||||
return kept, dropped
|
||||
}
|
||||
|
||||
// maxOutboundSubscriptionBytes caps a single outbound subscription response.
|
||||
// It is larger than the 2 MiB user-facing subscription cap because an outbound
|
||||
// subscription may aggregate many upstream outbounds into one document.
|
||||
@@ -347,24 +373,28 @@ func (s *OutboundSubscriptionService) fetchAndStore(sub *model.OutboundSubscript
|
||||
}
|
||||
identJSON, _ := json.Marshal(newIdent)
|
||||
|
||||
asAny := make([]any, len(parsed))
|
||||
for i := range parsed {
|
||||
asAny[i] = map[string]any(parsed[i])
|
||||
}
|
||||
kept, droppedByCore := filterOutboundsRejectedByCore(fmt.Sprintf("outbound sub %d", sub.Id), asAny)
|
||||
|
||||
// Persist the outbounds (as compact JSON array)
|
||||
obsJSON, _ := json.Marshal(parsed)
|
||||
obsJSON, _ := json.Marshal(kept)
|
||||
|
||||
sub.LastFetchedOutbounds = string(obsJSON)
|
||||
sub.LinkIdentities = string(identJSON)
|
||||
sub.LastUpdated = time.Now().Unix()
|
||||
sub.LastError = ""
|
||||
if len(droppedByCore) > 0 {
|
||||
sub.LastError = fmt.Sprintf("dropped %d outbound(s) the xray core rejects: %s", len(droppedByCore), droppedByCore[0])
|
||||
}
|
||||
|
||||
if err := database.GetDB().Save(sub).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Return as []any for the config merger
|
||||
result := make([]any, len(parsed))
|
||||
for i := range parsed {
|
||||
result[i] = parsed[i]
|
||||
}
|
||||
return result, nil
|
||||
return kept, nil
|
||||
}
|
||||
|
||||
func (s *OutboundSubscriptionService) recordError(sub *model.OutboundSubscription, err error) {
|
||||
@@ -456,6 +486,7 @@ func (s *OutboundSubscriptionService) activeOutboundsSplit() (prepend []any, app
|
||||
logger.Warningf("outbound sub %d has corrupt LastFetchedOutbounds: %v", sub.Id, err)
|
||||
continue
|
||||
}
|
||||
arr, _ = filterOutboundsRejectedByCore(fmt.Sprintf("outbound sub %d", sub.Id), arr)
|
||||
if sub.Prepend {
|
||||
prepend = append(prepend, arr...)
|
||||
} else {
|
||||
|
||||
+180
-53
@@ -10,6 +10,7 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
@@ -1425,44 +1426,26 @@ func (s *ServerService) ImportDB(file multipart.File) error {
|
||||
if database.IsPostgres() {
|
||||
return s.importPostgresDB(file)
|
||||
}
|
||||
// Check if the file is a SQLite database
|
||||
isValidDb, err := database.IsSQLiteDB(file)
|
||||
kind, err := sniffUploadKind(file)
|
||||
if err != nil {
|
||||
return common.NewErrorf("Error checking db file format: %v", err)
|
||||
return common.NewErrorf("Error reading uploaded file: %v", err)
|
||||
}
|
||||
if !isValidDb {
|
||||
return common.NewError("Invalid db file format")
|
||||
switch kind {
|
||||
case importKindSQLiteDB, importKindSQLiteDump:
|
||||
case importKindPgDump:
|
||||
return common.NewError("This file is a PostgreSQL backup; it can only be restored on a panel running PostgreSQL")
|
||||
default:
|
||||
return common.NewError("Invalid file: expected a SQLite database (.db) from Back Up or a SQLite migration dump (.dump)")
|
||||
}
|
||||
|
||||
// Reset the file reader to the beginning
|
||||
_, err = file.Seek(0, 0)
|
||||
if err != nil {
|
||||
return common.NewErrorf("Error resetting file reader: %v", err)
|
||||
}
|
||||
|
||||
// Save the file as a temporary file
|
||||
tempPath := fmt.Sprintf("%s.temp", config.GetDBPath())
|
||||
|
||||
// Remove the existing temporary file (if any)
|
||||
if _, err := os.Stat(tempPath); err == nil {
|
||||
if errRemove := os.Remove(tempPath); errRemove != nil {
|
||||
return common.NewErrorf("Error removing existing temporary db file: %v", errRemove)
|
||||
}
|
||||
}
|
||||
|
||||
// Create the temporary file
|
||||
tempFile, err := os.Create(tempPath)
|
||||
if err != nil {
|
||||
return common.NewErrorf("Error creating temporary db file: %v", err)
|
||||
}
|
||||
|
||||
// Robust deferred cleanup for the temporary file
|
||||
defer func() {
|
||||
if tempFile != nil {
|
||||
if cerr := tempFile.Close(); cerr != nil {
|
||||
logger.Warningf("Warning: failed to close temp file: %v", cerr)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(tempPath); err == nil {
|
||||
if rerr := os.Remove(tempPath); rerr != nil {
|
||||
logger.Warningf("Warning: failed to remove temp file: %v", rerr)
|
||||
@@ -1470,21 +1453,16 @@ func (s *ServerService) ImportDB(file multipart.File) error {
|
||||
}
|
||||
}()
|
||||
|
||||
// Save uploaded file to temporary file
|
||||
if _, err = io.Copy(tempFile, file); err != nil {
|
||||
return common.NewErrorf("Error saving db: %v", err)
|
||||
if err := stageSQLiteUpload(file, kind, tempPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Close temp file before opening via sqlite
|
||||
if err = tempFile.Close(); err != nil {
|
||||
return common.NewErrorf("Error closing temporary db file: %v", err)
|
||||
}
|
||||
tempFile = nil
|
||||
|
||||
// Validate integrity (no migrations / side effects)
|
||||
if err = database.ValidateSQLiteDB(tempPath); err != nil {
|
||||
return common.NewErrorf("Invalid or corrupt db file: %v", err)
|
||||
}
|
||||
if err = database.PrepareSQLiteForMigration(tempPath); err != nil {
|
||||
return common.NewErrorf("This file cannot be imported: %v", err)
|
||||
}
|
||||
|
||||
xrayStopped := true
|
||||
defer func() {
|
||||
@@ -1502,6 +1480,19 @@ func (s *ServerService) ImportDB(file multipart.File) error {
|
||||
logger.Warningf("Failed to close existing DB before replacement: %v", errClose)
|
||||
}
|
||||
|
||||
// Registered after the xray-restart defer so it runs first (LIFO): every
|
||||
// error return below leaves a database file at the configured path, and the
|
||||
// restart needs an open pool to build the xray config from it.
|
||||
dbReopened := false
|
||||
defer func() {
|
||||
if dbReopened {
|
||||
return
|
||||
}
|
||||
if errReopen := database.InitDB(config.GetDBPath()); errReopen != nil {
|
||||
logger.Warningf("Failed to reopen the database after import error: %v", errReopen)
|
||||
}
|
||||
}()
|
||||
|
||||
// Backup the current database for fallback
|
||||
fallbackPath := fmt.Sprintf("%s.backup", config.GetDBPath())
|
||||
|
||||
@@ -1517,15 +1508,6 @@ func (s *ServerService) ImportDB(file multipart.File) error {
|
||||
return common.NewErrorf("Error backing up current db file: %v", err)
|
||||
}
|
||||
|
||||
// Defer fallback cleanup ONLY if everything goes well
|
||||
defer func() {
|
||||
if _, err := os.Stat(fallbackPath); err == nil {
|
||||
if rerr := os.Remove(fallbackPath); rerr != nil {
|
||||
logger.Warningf("Warning: failed to remove fallback file: %v", rerr)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Move temp to DB path
|
||||
if err = os.Rename(tempPath, config.GetDBPath()); err != nil {
|
||||
// Restore from fallback
|
||||
@@ -1537,19 +1519,30 @@ func (s *ServerService) ImportDB(file multipart.File) error {
|
||||
|
||||
// Open & migrate new DB
|
||||
if err = database.InitDB(config.GetDBPath()); err != nil {
|
||||
// A failed InitDB still holds the imported file open; close before the
|
||||
// rename or Windows refuses to replace it.
|
||||
if errClose := database.CloseDB(); errClose != nil {
|
||||
logger.Warningf("Failed to close the imported DB before restoring fallback: %v", errClose)
|
||||
}
|
||||
if errRename := os.Rename(fallbackPath, config.GetDBPath()); errRename != nil {
|
||||
return common.NewErrorf("Error migrating db and restoring fallback: %v", errRename)
|
||||
}
|
||||
return common.NewErrorf("Error migrating db: %v", err)
|
||||
}
|
||||
dbReopened = true
|
||||
|
||||
s.inboundService.MigrateDB()
|
||||
|
||||
xrayStopped = false
|
||||
if err = s.RestartXrayService(); err != nil {
|
||||
return common.NewErrorf("Imported DB but failed to start Xray: %v", err)
|
||||
return common.NewErrorf("Imported DB but failed to start Xray: %v; the previous database was kept at %s", err, fallbackPath)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(fallbackPath); err == nil {
|
||||
if rerr := os.Remove(fallbackPath); rerr != nil {
|
||||
logger.Warningf("Warning: failed to remove fallback file: %v", rerr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1657,18 +1650,59 @@ func parsePgToolVersion(versionOutput string) string {
|
||||
return pgToolVersionPattern.FindString(versionOutput)
|
||||
}
|
||||
|
||||
func (s *ServerService) importPostgresDB(file multipart.File) error {
|
||||
header := make([]byte, 5)
|
||||
if _, err := file.ReadAt(header, 0); err != nil {
|
||||
return common.NewErrorf("Error reading dump file: %v", err)
|
||||
const (
|
||||
importKindUnknown = iota
|
||||
importKindPgDump
|
||||
importKindSQLiteDB
|
||||
importKindSQLiteDump
|
||||
)
|
||||
|
||||
// sniffImportKind classifies an uploaded restore file by its leading bytes:
|
||||
// a pg_dump custom archive, a raw SQLite database, or a SQLite SQL text dump.
|
||||
func sniffImportKind(header []byte) int {
|
||||
if bytes.HasPrefix(header, []byte("PGDMP")) {
|
||||
return importKindPgDump
|
||||
}
|
||||
if string(header) != "PGDMP" {
|
||||
return common.NewError("Invalid file: expected a PostgreSQL custom-format dump (.dump) created by this panel's Back Up")
|
||||
if bytes.HasPrefix(header, []byte("SQLite format 3\x00")) {
|
||||
return importKindSQLiteDB
|
||||
}
|
||||
text := bytes.TrimLeft(bytes.TrimPrefix(header, []byte("\xef\xbb\xbf")), " \t\r\n")
|
||||
if bytes.HasPrefix(text, []byte("PRAGMA")) || bytes.HasPrefix(text, []byte("BEGIN TRANSACTION")) {
|
||||
return importKindSQLiteDump
|
||||
}
|
||||
return importKindUnknown
|
||||
}
|
||||
|
||||
func sniffUploadKind(file multipart.File) (int, error) {
|
||||
header := make([]byte, 64)
|
||||
n, err := file.ReadAt(header, 0)
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return importKindUnknown, err
|
||||
}
|
||||
if _, err := file.Seek(0, 0); err != nil {
|
||||
return common.NewErrorf("Error resetting file reader: %v", err)
|
||||
return importKindUnknown, err
|
||||
}
|
||||
return sniffImportKind(header[:n]), nil
|
||||
}
|
||||
|
||||
func (s *ServerService) importPostgresDB(file multipart.File) error {
|
||||
kind, err := sniffUploadKind(file)
|
||||
if err != nil {
|
||||
return common.NewErrorf("Error reading uploaded file: %v", err)
|
||||
}
|
||||
switch kind {
|
||||
case importKindPgDump:
|
||||
return s.restorePostgresDump(file)
|
||||
case importKindSQLiteDB:
|
||||
return s.migrateSQLiteIntoPostgres(file, false)
|
||||
case importKindSQLiteDump:
|
||||
return s.migrateSQLiteIntoPostgres(file, true)
|
||||
default:
|
||||
return common.NewError("Invalid file: expected a PostgreSQL custom-format dump (.dump) from this panel's Back Up, a SQLite database (.db), or a SQLite migration dump")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ServerService) restorePostgresDump(file multipart.File) error {
|
||||
bin, err := exec.LookPath("pg_restore")
|
||||
if err != nil {
|
||||
return common.NewError("pg_restore not found on the server; install the postgresql-client package to restore a PostgreSQL database")
|
||||
@@ -1737,6 +1771,99 @@ func (s *ServerService) importPostgresDB(file multipart.File) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ServerService) migrateSQLiteIntoPostgres(file multipart.File, isSQLDump bool) error {
|
||||
tempDir, err := os.MkdirTemp("", "x-ui-pg-migrate-*")
|
||||
if err != nil {
|
||||
return common.NewErrorf("Error creating temporary folder: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
uploadPath := filepath.Join(tempDir, "upload.db")
|
||||
if isSQLDump {
|
||||
uploadPath = filepath.Join(tempDir, "upload.dump")
|
||||
}
|
||||
if err := saveUploadedFile(file, uploadPath); err != nil {
|
||||
return common.NewErrorf("Error saving uploaded file: %v", err)
|
||||
}
|
||||
|
||||
dbPath := uploadPath
|
||||
if isSQLDump {
|
||||
dbPath = filepath.Join(tempDir, "restored.db")
|
||||
if err := database.RestoreSQLite(uploadPath, dbPath); err != nil {
|
||||
return common.NewErrorf("Error rebuilding a SQLite database from the migration dump: %v", err)
|
||||
}
|
||||
}
|
||||
if err := database.ValidateSQLiteDB(dbPath); err != nil {
|
||||
return common.NewErrorf("Invalid or corrupt db file: %v", err)
|
||||
}
|
||||
if err := database.PrepareSQLiteForMigration(dbPath); err != nil {
|
||||
return common.NewErrorf("This file cannot be imported: %v", err)
|
||||
}
|
||||
|
||||
xrayStopped := true
|
||||
defer func() {
|
||||
if xrayStopped {
|
||||
if errR := s.RestartXrayService(); errR != nil {
|
||||
logger.Warningf("Failed to restart Xray after DB restore error: %v", errR)
|
||||
}
|
||||
}
|
||||
}()
|
||||
if errStop := s.StopXrayService(); errStop != nil {
|
||||
logger.Warningf("Failed to stop Xray before DB restore: %v", errStop)
|
||||
}
|
||||
|
||||
if errClose := database.CloseDB(); errClose != nil {
|
||||
logger.Warningf("Failed to close existing DB before restore: %v", errClose)
|
||||
}
|
||||
|
||||
migrateErr := database.MigrateData(dbPath, config.GetDBDSN())
|
||||
|
||||
if errInit := database.InitDB(config.GetDBPath()); errInit != nil {
|
||||
return common.NewErrorf("Restore finished but reopening the database failed: %v", errInit)
|
||||
}
|
||||
s.inboundService.MigrateDB()
|
||||
|
||||
if migrateErr != nil {
|
||||
return common.NewErrorf("Importing the SQLite data into PostgreSQL failed: %v; the import runs in a single transaction, so the database was left unchanged", migrateErr)
|
||||
}
|
||||
|
||||
xrayStopped = false
|
||||
if err := s.RestartXrayService(); err != nil {
|
||||
return common.NewErrorf("Restored DB but failed to start Xray: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveUploadedFile(file multipart.File, dstPath string) error {
|
||||
dst, err := os.Create(dstPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(dst, file); err != nil {
|
||||
dst.Close()
|
||||
return err
|
||||
}
|
||||
return dst.Close()
|
||||
}
|
||||
|
||||
func stageSQLiteUpload(file multipart.File, kind int, tempPath string) error {
|
||||
if kind == importKindSQLiteDump {
|
||||
dumpPath := tempPath + ".dump"
|
||||
defer os.Remove(dumpPath)
|
||||
if err := saveUploadedFile(file, dumpPath); err != nil {
|
||||
return common.NewErrorf("Error saving migration dump: %v", err)
|
||||
}
|
||||
if err := database.RestoreSQLite(dumpPath, tempPath); err != nil {
|
||||
return common.NewErrorf("Error rebuilding a SQLite database from the migration dump: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := saveUploadedFile(file, tempPath); err != nil {
|
||||
return common.NewErrorf("Error saving db: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsValidGeofileName validates that the filename is safe for geofile operations.
|
||||
// It checks for path traversal attempts and ensures the filename contains only safe characters.
|
||||
func (s *ServerService) IsValidGeofileName(filename string) bool {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
)
|
||||
|
||||
func TestSniffImportKind(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
header []byte
|
||||
want int
|
||||
}{
|
||||
{"pg custom archive", []byte("PGDMP\x01\x10\x04"), importKindPgDump},
|
||||
{"raw sqlite database", []byte("SQLite format 3\x00rest of header"), importKindSQLiteDB},
|
||||
{"sqlite cli dump without pragma", []byte("BEGIN TRANSACTION;\nCREATE TABLE t(i);"), importKindSQLiteDump},
|
||||
{"bom and whitespace before pragma", []byte("\xef\xbb\xbf\r\n PRAGMA foreign_keys=OFF;"), importKindSQLiteDump},
|
||||
{"plain-format postgres dump", []byte("--\n-- PostgreSQL database dump\n--"), importKindUnknown},
|
||||
{"empty file", nil, importKindUnknown},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := sniffImportKind(tc.header); got != tc.want {
|
||||
t.Errorf("sniffImportKind(%q) = %d, want %d", tc.header, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("panel migration dump", func(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "x-ui.db")
|
||||
if err := database.InitDB(dbPath); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
dump, err := database.DumpSQLiteToBytes(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("DumpSQLiteToBytes: %v", err)
|
||||
}
|
||||
if got := sniffImportKind(dump[:64]); got != importKindSQLiteDump {
|
||||
t.Errorf("sniffImportKind(real migration dump) = %d, want %d", got, importKindSQLiteDump)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
)
|
||||
|
||||
func TestStageSQLiteUploadRebuildsFromDump(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "x-ui.db")
|
||||
if err := database.InitDB(dbPath); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
dump, err := database.DumpSQLiteToBytes(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("DumpSQLiteToBytes: %v", err)
|
||||
}
|
||||
|
||||
uploadPath := filepath.Join(dir, "upload.dump")
|
||||
if err := os.WriteFile(uploadPath, dump, 0o644); err != nil {
|
||||
t.Fatalf("write upload: %v", err)
|
||||
}
|
||||
upload, err := os.Open(uploadPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open upload: %v", err)
|
||||
}
|
||||
defer upload.Close()
|
||||
|
||||
staged := filepath.Join(dir, "x-ui.db.temp")
|
||||
if err := stageSQLiteUpload(upload, importKindSQLiteDump, staged); err != nil {
|
||||
t.Fatalf("stageSQLiteUpload: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(staged + ".dump"); !os.IsNotExist(err) {
|
||||
t.Errorf("intermediate dump file %s.dump was not cleaned up", staged)
|
||||
}
|
||||
if err := database.ValidateSQLiteDB(staged); err != nil {
|
||||
t.Errorf("staged database fails integrity check: %v", err)
|
||||
}
|
||||
if err := database.PrepareSQLiteForMigration(staged); err != nil {
|
||||
t.Errorf("staged database fails the panel pre-flight: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCanonicalizeStreamNetworkKey covers xray-core v26.7.11's "method" alias
|
||||
// for streamSettings "network": a config keyed on "method" (from an imported
|
||||
// or API-authored inbound) must be folded back to the panel-canonical
|
||||
// "network" key that every downstream reader — link generation, port-conflict
|
||||
// detection, flow eligibility — depends on.
|
||||
func TestCanonicalizeStreamNetworkKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
wantNetwork string
|
||||
wantMethod bool
|
||||
}{
|
||||
{
|
||||
name: "method alias becomes network",
|
||||
in: `{"method": "ws", "security": "tls"}`,
|
||||
wantNetwork: "ws",
|
||||
},
|
||||
{
|
||||
name: "method wins when both present",
|
||||
in: `{"method": "grpc", "network": "tcp"}`,
|
||||
wantNetwork: "grpc",
|
||||
},
|
||||
{
|
||||
name: "plain network untouched",
|
||||
in: `{"network": "tcp"}`,
|
||||
wantNetwork: "tcp",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := canonicalizeStreamNetworkKey(tc.in)
|
||||
var stream map[string]any
|
||||
if err := json.Unmarshal([]byte(got), &stream); err != nil {
|
||||
t.Fatalf("result is not valid JSON: %v", err)
|
||||
}
|
||||
if stream["network"] != tc.wantNetwork {
|
||||
t.Fatalf("network = %v, want %q", stream["network"], tc.wantNetwork)
|
||||
}
|
||||
if _, hasMethod := stream["method"]; hasMethod {
|
||||
t.Fatalf("method key must be removed, got %s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalizeStreamNetworkKey_EmptyPassthrough(t *testing.T) {
|
||||
if got := canonicalizeStreamNetworkKey(""); got != "" {
|
||||
t.Fatalf("empty stream must round-trip, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,21 @@ func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error {
|
||||
if err != nil {
|
||||
return common.NewError("xray template config invalid:", err)
|
||||
}
|
||||
if len(xrayConfig.OutboundConfigs) > 0 {
|
||||
var outbounds []json.RawMessage
|
||||
if err := json.Unmarshal(xrayConfig.OutboundConfigs, &outbounds); err != nil {
|
||||
return common.NewError("xray template config invalid: outbounds is not an array:", err)
|
||||
}
|
||||
for _, outbound := range outbounds {
|
||||
if err := xray.ValidateOutboundConfig(outbound); err != nil {
|
||||
tagged := struct {
|
||||
Tag string `json:"tag"`
|
||||
}{}
|
||||
_ = json.Unmarshal(outbound, &tagged)
|
||||
return common.NewError("xray core rejects outbound \""+tagged.Tag+"\":", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -261,19 +261,18 @@
|
||||
"backup": "نسخ احتياطي",
|
||||
"backupTitle": "نسخ احتياطي واستعادة",
|
||||
"exportDatabase": "اخزن نسخة",
|
||||
"exportDatabaseDesc": "اضغط عشان تحمل ملف .db يحتوي على نسخة احتياطية لقاعدة البيانات الحالية على جهازك.",
|
||||
"exportDatabaseDesc": "اضغط عشان تحمل ملف .db يحتوي على نسخة احتياطية لقاعدة البيانات الحالية على جهازك. نفس الملف ممكن كمان يترجع على لوحة شغالة بـ PostgreSQL.",
|
||||
"importDatabase": "استرجاع",
|
||||
"importDatabaseDesc": "اضغط عشان تختار وتحمل ملف .db من جهازك لاسترجاع قاعدة البيانات من نسخة احتياطية.",
|
||||
"importDatabaseDesc": "اضغط عشان تختار وتحمل نسخة احتياطية .db أو ملف ترحيل (.dump) من جهازك لاسترجاع قاعدة البيانات.",
|
||||
"importDatabaseSuccess": "تم استيراد قاعدة البيانات بنجاح",
|
||||
"importDatabaseError": "حدث خطأ أثناء استيراد قاعدة البيانات",
|
||||
"readDatabaseError": "حدث خطأ أثناء قراءة قاعدة البيانات",
|
||||
"getDatabaseError": "حدث خطأ أثناء استرجاع قاعدة البيانات",
|
||||
"getConfigError": "حدث خطأ أثناء استرجاع ملف الإعدادات",
|
||||
"backupPostgresNote": "تعمل هذه اللوحة على PostgreSQL. يقوم «النسخ الاحتياطي» بتنزيل أرشيف pg_dump (.dump)، و«الاستعادة» تعيد تحميله عبر pg_restore. يجب أن تكون أدوات عميل PostgreSQL (pg_dump و pg_restore) مثبَّتة على الخادم.",
|
||||
"backupPostgresNote": "تعمل هذه اللوحة على PostgreSQL. يقوم «النسخ الاحتياطي» بتنزيل أرشيف pg_dump (.dump)، و«الاستعادة» تعيد تحميله عبر pg_restore. تقبل «الاستعادة» أيضًا قاعدة بيانات SQLite (.db) أو ملف ترحيل SQLite وتستورد بياناتها إلى PostgreSQL. يجب أن تكون أدوات عميل PostgreSQL (pg_dump و pg_restore) مثبَّتة على الخادم.",
|
||||
"exportDatabasePgDesc": "انقر لتنزيل نسخة PostgreSQL (.dump) من قاعدة بياناتك الحالية إلى جهازك.",
|
||||
"importDatabasePgDesc": "انقر لاختيار ورفع ملف .dump لاستعادة قاعدة بيانات PostgreSQL. سيؤدي هذا إلى استبدال جميع البيانات الحالية.",
|
||||
"importDatabasePgDesc": "انقر لاختيار ورفع نسخة احتياطية من PostgreSQL (.dump) أو قاعدة بيانات SQLite (.db) أو ملف ترحيل SQLite لاستعادة قاعدة البيانات. سيؤدي هذا إلى استبدال جميع البيانات الحالية.",
|
||||
"migrationDownload": "تنزيل ملف الترحيل",
|
||||
"migrationDownloadDesc": "انقر لتنزيل تصدير .dump محمول (نص SQL) لقاعدة بيانات SQLite الخاصة بك.",
|
||||
"migrationDownloadPgDesc": "انقر لتنزيل قاعدة بيانات SQLite بامتداد .db مبنية من بيانات PostgreSQL الخاصة بك، جاهزة لتشغيل هذه اللوحة على SQLite."
|
||||
},
|
||||
"inbounds": {
|
||||
@@ -1622,6 +1621,8 @@
|
||||
"tag": "الوسم",
|
||||
"tagDesc": "تاج فريد",
|
||||
"address": "العنوان",
|
||||
"egress": "Egress",
|
||||
"egressHint": "Run an HTTP test to show egress IP and country.",
|
||||
"reverse": "عكسي",
|
||||
"domain": "النطاق",
|
||||
"type": "النوع",
|
||||
|
||||
@@ -261,19 +261,18 @@
|
||||
"backup": "Backup",
|
||||
"backupTitle": "Backup & Restore",
|
||||
"exportDatabase": "Back Up",
|
||||
"exportDatabaseDesc": "Click to download a .db file containing a backup of your current database to your device.",
|
||||
"exportDatabaseDesc": "Click to download a .db file containing a backup of your current database to your device. The same file can also be restored into a panel running on PostgreSQL.",
|
||||
"importDatabase": "Restore",
|
||||
"importDatabaseDesc": "Click to select and upload a .db file from your device to restore your database from a backup.",
|
||||
"importDatabaseDesc": "Click to select and upload a .db backup or a migration dump (.dump) from your device to restore your database.",
|
||||
"importDatabaseSuccess": "The database has been successfully imported.",
|
||||
"importDatabaseError": "An error occurred while importing the database.",
|
||||
"readDatabaseError": "An error occurred while reading the database.",
|
||||
"getDatabaseError": "An error occurred while retrieving the database.",
|
||||
"getConfigError": "An error occurred while retrieving the config file.",
|
||||
"backupPostgresNote": "This panel runs on PostgreSQL. Back Up downloads a pg_dump archive (.dump) and Restore loads it back with pg_restore. The server needs the PostgreSQL client tools (pg_dump and pg_restore) installed.",
|
||||
"backupPostgresNote": "This panel runs on PostgreSQL. Back Up downloads a pg_dump archive (.dump) and Restore loads it back with pg_restore. Restore also accepts a SQLite database (.db) or a SQLite migration dump and imports its data into PostgreSQL. The server needs the PostgreSQL client tools (pg_dump and pg_restore) installed.",
|
||||
"exportDatabasePgDesc": "Click to download a PostgreSQL dump (.dump) of your current database to your device.",
|
||||
"importDatabasePgDesc": "Click to select and upload a .dump file to restore your PostgreSQL database. This replaces all current data.",
|
||||
"importDatabasePgDesc": "Click to select and upload a PostgreSQL backup (.dump), a SQLite database (.db), or a SQLite migration dump to restore your database. This replaces all current data.",
|
||||
"migrationDownload": "Download Migration",
|
||||
"migrationDownloadDesc": "Click to download a portable .dump (SQL text) export of your SQLite database.",
|
||||
"migrationDownloadPgDesc": "Click to download a .db SQLite database built from your PostgreSQL data, ready to run this panel on SQLite."
|
||||
},
|
||||
"inbounds": {
|
||||
@@ -1739,6 +1738,8 @@
|
||||
"tag": "Tag",
|
||||
"tagDesc": "Unique Tag",
|
||||
"address": "Address",
|
||||
"egress": "Egress",
|
||||
"egressHint": "Run an HTTP test to show egress IP and country.",
|
||||
"reverse": "Reverse",
|
||||
"domain": "Domain",
|
||||
"type": "Type",
|
||||
|
||||
@@ -261,19 +261,18 @@
|
||||
"backup": "Copia de seguridad",
|
||||
"backupTitle": "Copia & Restauración",
|
||||
"exportDatabase": "Copia de seguridad",
|
||||
"exportDatabaseDesc": "Haz clic para descargar un archivo .db que contiene una copia de seguridad de tu base de datos actual en tu dispositivo.",
|
||||
"exportDatabaseDesc": "Haz clic para descargar un archivo .db que contiene una copia de seguridad de tu base de datos actual en tu dispositivo. El mismo archivo también puede restaurarse en un panel que funcione con PostgreSQL.",
|
||||
"importDatabase": "Restaurar",
|
||||
"importDatabaseDesc": "Haz clic para seleccionar y cargar un archivo .db desde tu dispositivo para restaurar tu base de datos desde una copia de seguridad.",
|
||||
"importDatabaseDesc": "Haz clic para seleccionar y cargar una copia de seguridad .db o un volcado de migración (.dump) desde tu dispositivo para restaurar tu base de datos.",
|
||||
"importDatabaseSuccess": "La base de datos se ha importado correctamente",
|
||||
"importDatabaseError": "Ocurrió un error al importar la base de datos",
|
||||
"readDatabaseError": "Ocurrió un error al leer la base de datos",
|
||||
"getDatabaseError": "Ocurrió un error al obtener la base de datos",
|
||||
"getConfigError": "Ocurrió un error al obtener el archivo de configuración",
|
||||
"backupPostgresNote": "Este panel funciona con PostgreSQL. «Copia de seguridad» descarga un archivo pg_dump (.dump) y «Restaurar» lo vuelve a cargar con pg_restore. El servidor necesita tener instaladas las herramientas cliente de PostgreSQL (pg_dump y pg_restore).",
|
||||
"backupPostgresNote": "Este panel funciona con PostgreSQL. «Copia de seguridad» descarga un archivo pg_dump (.dump) y «Restaurar» lo vuelve a cargar con pg_restore. «Restaurar» también acepta una base de datos SQLite (.db) o un volcado de migración de SQLite e importa sus datos a PostgreSQL. El servidor necesita tener instaladas las herramientas cliente de PostgreSQL (pg_dump y pg_restore).",
|
||||
"exportDatabasePgDesc": "Haz clic para descargar un volcado de PostgreSQL (.dump) de tu base de datos actual en tu dispositivo.",
|
||||
"importDatabasePgDesc": "Haz clic para seleccionar y subir un archivo .dump y restaurar tu base de datos PostgreSQL. Esto reemplaza todos los datos actuales.",
|
||||
"importDatabasePgDesc": "Haz clic para seleccionar y subir una copia de seguridad de PostgreSQL (.dump), una base de datos SQLite (.db) o un volcado de migración de SQLite para restaurar tu base de datos. Esto reemplaza todos los datos actuales.",
|
||||
"migrationDownload": "Descargar migración",
|
||||
"migrationDownloadDesc": "Haz clic para descargar una exportación portable .dump (texto SQL) de tu base de datos SQLite.",
|
||||
"migrationDownloadPgDesc": "Haz clic para descargar una base de datos SQLite .db creada a partir de tus datos de PostgreSQL, lista para ejecutar este panel en SQLite."
|
||||
},
|
||||
"inbounds": {
|
||||
@@ -1622,6 +1621,8 @@
|
||||
"tag": "Etiqueta",
|
||||
"tagDesc": "etiqueta única",
|
||||
"address": "Dirección",
|
||||
"egress": "Egress",
|
||||
"egressHint": "Run an HTTP test to show egress IP and country.",
|
||||
"reverse": "Reverso",
|
||||
"domain": "Dominio",
|
||||
"type": "Tipo",
|
||||
|
||||
@@ -261,19 +261,18 @@
|
||||
"backup": "پشتیبانگیری",
|
||||
"backupTitle": "پشتیبانگیری و بازیابی",
|
||||
"exportDatabase": "پشتیبانگیری",
|
||||
"exportDatabaseDesc": "برای دانلود یک فایل .db حاوی پشتیبان از پایگاه داده فعلی خود به دستگاهتان کلیک کنید.",
|
||||
"exportDatabaseDesc": "برای دانلود یک فایل .db حاوی پشتیبان از پایگاه داده فعلی خود به دستگاهتان کلیک کنید. همین فایل در پنلی که روی PostgreSQL اجرا میشود نیز قابل بازیابی است.",
|
||||
"importDatabase": "بازیابی",
|
||||
"importDatabaseDesc": "برای انتخاب و آپلود یک فایل .db از دستگاهتان و بازیابی پایگاه داده از یک پشتیبان کلیک کنید.",
|
||||
"importDatabaseDesc": "برای انتخاب و آپلود یک پشتیبان .db یا فایل مهاجرت .dump از دستگاهتان جهت بازیابی پایگاه داده کلیک کنید.",
|
||||
"importDatabaseSuccess": "پایگاه داده با موفقیت وارد شد",
|
||||
"importDatabaseError": "خطا در وارد کردن پایگاه داده",
|
||||
"readDatabaseError": "خطا در خواندن پایگاه داده",
|
||||
"getDatabaseError": "خطا در دریافت پایگاه داده",
|
||||
"getConfigError": "خطا در دریافت فایل پیکربندی",
|
||||
"backupPostgresNote": "این پنل روی PostgreSQL اجرا میشود. «پشتیبانگیری» یک آرشیو pg_dump (.dump) دانلود میکند و «بازیابی» آن را با pg_restore بازمیگرداند. سرور باید ابزارهای کلاینت PostgreSQL (pg_dump و pg_restore) را نصب داشته باشد.",
|
||||
"backupPostgresNote": "این پنل روی PostgreSQL اجرا میشود. «پشتیبانگیری» یک آرشیو pg_dump (.dump) دانلود میکند و «بازیابی» آن را با pg_restore بازمیگرداند. «بازیابی» همچنین پایگاهدادهٔ SQLite (.db) یا فایل مهاجرت SQLite را میپذیرد و دادههای آن را به PostgreSQL وارد میکند. سرور باید ابزارهای کلاینت PostgreSQL (pg_dump و pg_restore) را نصب داشته باشد.",
|
||||
"exportDatabasePgDesc": "برای دانلود یک دامپ PostgreSQL (.dump) از پایگاه داده فعلی روی دستگاهتان کلیک کنید.",
|
||||
"importDatabasePgDesc": "برای انتخاب و بارگذاری یک فایل .dump جهت بازیابی پایگاه داده PostgreSQL کلیک کنید. این کار همه دادههای فعلی را جایگزین میکند.",
|
||||
"importDatabasePgDesc": "برای انتخاب و بارگذاری یک پشتیبان PostgreSQL (.dump)، پایگاهدادهٔ SQLite (.db) یا فایل مهاجرت SQLite جهت بازیابی پایگاه داده کلیک کنید. این کار همه دادههای فعلی را جایگزین میکند.",
|
||||
"migrationDownload": "دانلود فایل مهاجرت",
|
||||
"migrationDownloadDesc": "برای دانلود یک خروجی قابلحمل .dump (متن SQL) از پایگاهدادهٔ SQLite خود کلیک کنید.",
|
||||
"migrationDownloadPgDesc": "برای دانلود یک پایگاهدادهٔ SQLite با پسوند .db که از دادههای PostgreSQL شما ساخته میشود کلیک کنید؛ آمادهٔ اجرای این پنل روی SQLite."
|
||||
},
|
||||
"inbounds": {
|
||||
@@ -1622,6 +1621,8 @@
|
||||
"tag": "تگ",
|
||||
"tagDesc": "برچسب یگانه",
|
||||
"address": "آدرس",
|
||||
"egress": "Egress",
|
||||
"egressHint": "Run an HTTP test to show egress IP and country.",
|
||||
"reverse": "معکوس",
|
||||
"domain": "دامنه",
|
||||
"type": "نوع",
|
||||
|
||||
@@ -261,19 +261,18 @@
|
||||
"backup": "Cadangan",
|
||||
"backupTitle": "Cadangan & Pulihkan",
|
||||
"exportDatabase": "Cadangkan",
|
||||
"exportDatabaseDesc": "Klik untuk mengunduh file .db yang berisi cadangan dari database Anda saat ini ke perangkat Anda.",
|
||||
"exportDatabaseDesc": "Klik untuk mengunduh file .db yang berisi cadangan dari database Anda saat ini ke perangkat Anda. Berkas yang sama juga dapat dipulihkan ke panel yang berjalan di PostgreSQL.",
|
||||
"importDatabase": "Pulihkan",
|
||||
"importDatabaseDesc": "Klik untuk memilih dan mengunggah file .db dari perangkat Anda untuk memulihkan database dari cadangan.",
|
||||
"importDatabaseDesc": "Klik untuk memilih dan mengunggah cadangan .db atau dump migrasi (.dump) dari perangkat Anda untuk memulihkan database.",
|
||||
"importDatabaseSuccess": "Database berhasil diimpor",
|
||||
"importDatabaseError": "Terjadi kesalahan saat mengimpor database",
|
||||
"readDatabaseError": "Terjadi kesalahan saat membaca database",
|
||||
"getDatabaseError": "Terjadi kesalahan saat mengambil database",
|
||||
"getConfigError": "Terjadi kesalahan saat mengambil file konfigurasi",
|
||||
"backupPostgresNote": "Panel ini berjalan di PostgreSQL. «Cadangkan» mengunduh arsip pg_dump (.dump) dan «Pulihkan» memuatnya kembali dengan pg_restore. Server memerlukan alat klien PostgreSQL (pg_dump dan pg_restore) terpasang.",
|
||||
"backupPostgresNote": "Panel ini berjalan di PostgreSQL. «Cadangkan» mengunduh arsip pg_dump (.dump) dan «Pulihkan» memuatnya kembali dengan pg_restore. «Pulihkan» juga menerima basis data SQLite (.db) atau dump migrasi SQLite dan mengimpor datanya ke PostgreSQL. Server memerlukan alat klien PostgreSQL (pg_dump dan pg_restore) terpasang.",
|
||||
"exportDatabasePgDesc": "Klik untuk mengunduh dump PostgreSQL (.dump) dari basis data Anda saat ini ke perangkat Anda.",
|
||||
"importDatabasePgDesc": "Klik untuk memilih dan mengunggah berkas .dump guna memulihkan basis data PostgreSQL Anda. Ini menggantikan semua data saat ini.",
|
||||
"importDatabasePgDesc": "Klik untuk memilih dan mengunggah cadangan PostgreSQL (.dump), basis data SQLite (.db), atau dump migrasi SQLite guna memulihkan basis data Anda. Ini menggantikan semua data saat ini.",
|
||||
"migrationDownload": "Unduh migrasi",
|
||||
"migrationDownloadDesc": "Klik untuk mengunduh ekspor .dump (teks SQL) portabel dari basis data SQLite Anda.",
|
||||
"migrationDownloadPgDesc": "Klik untuk mengunduh basis data SQLite .db yang dibuat dari data PostgreSQL Anda, siap menjalankan panel ini di SQLite."
|
||||
},
|
||||
"inbounds": {
|
||||
@@ -1622,6 +1621,8 @@
|
||||
"tag": "Tag",
|
||||
"tagDesc": "Tag Unik",
|
||||
"address": "Alamat",
|
||||
"egress": "Egress",
|
||||
"egressHint": "Run an HTTP test to show egress IP and country.",
|
||||
"reverse": "Revers",
|
||||
"domain": "Domain",
|
||||
"type": "Tipe",
|
||||
|
||||
@@ -261,19 +261,18 @@
|
||||
"backup": "バックアップ",
|
||||
"backupTitle": "バックアップと復元",
|
||||
"exportDatabase": "バックアップ",
|
||||
"exportDatabaseDesc": "クリックして、現在のデータベースのバックアップを含む .db ファイルをデバイスにダウンロードします。",
|
||||
"exportDatabaseDesc": "クリックして、現在のデータベースのバックアップを含む .db ファイルをデバイスにダウンロードします。同じファイルは PostgreSQL で動作するパネルにも復元できます。",
|
||||
"importDatabase": "復元",
|
||||
"importDatabaseDesc": "クリックして、デバイスから .db ファイルを選択し、アップロードしてバックアップからデータベースを復元します。",
|
||||
"importDatabaseDesc": "クリックして、デバイスから .db バックアップまたは移行ダンプ (.dump) を選択し、アップロードしてデータベースを復元します。",
|
||||
"importDatabaseSuccess": "データベースのインポートに成功しました",
|
||||
"importDatabaseError": "データベースのインポート中にエラーが発生しました",
|
||||
"readDatabaseError": "データベースの読み取り中にエラーが発生しました",
|
||||
"getDatabaseError": "データベースの取得中にエラーが発生しました",
|
||||
"getConfigError": "設定ファイルの取得中にエラーが発生しました",
|
||||
"backupPostgresNote": "このパネルは PostgreSQL で動作しています。「バックアップ」は pg_dump アーカイブ (.dump) をダウンロードし、「復元」は pg_restore で読み込み直します。サーバーに PostgreSQL クライアントツール (pg_dump と pg_restore) がインストールされている必要があります。",
|
||||
"backupPostgresNote": "このパネルは PostgreSQL で動作しています。「バックアップ」は pg_dump アーカイブ (.dump) をダウンロードし、「復元」は pg_restore で読み込み直します。「復元」は SQLite データベース (.db) や SQLite 移行ダンプも受け付け、そのデータを PostgreSQL に取り込みます。サーバーに PostgreSQL クライアントツール (pg_dump と pg_restore) がインストールされている必要があります。",
|
||||
"exportDatabasePgDesc": "現在のデータベースの PostgreSQL ダンプ (.dump) を端末にダウンロードするにはクリックしてください。",
|
||||
"importDatabasePgDesc": "PostgreSQL データベースを復元するために .dump ファイルを選択してアップロードするにはクリックしてください。現在のすべてのデータが置き換えられます。",
|
||||
"importDatabasePgDesc": "データベースを復元するために PostgreSQL バックアップ (.dump)、SQLite データベース (.db)、または SQLite 移行ダンプを選択してアップロードするにはクリックしてください。現在のすべてのデータが置き換えられます。",
|
||||
"migrationDownload": "移行ファイルをダウンロード",
|
||||
"migrationDownloadDesc": "SQLite データベースのポータブルな .dump(SQL テキスト)エクスポートをダウンロードするにはクリックします。",
|
||||
"migrationDownloadPgDesc": "PostgreSQL のデータから作成した .db SQLite データベースをダウンロードします。このパネルを SQLite で実行する準備が整います。"
|
||||
},
|
||||
"inbounds": {
|
||||
@@ -1622,6 +1621,8 @@
|
||||
"tag": "タグ",
|
||||
"tagDesc": "一意のタグ",
|
||||
"address": "アドレス",
|
||||
"egress": "Egress",
|
||||
"egressHint": "Run an HTTP test to show egress IP and country.",
|
||||
"reverse": "リバース",
|
||||
"domain": "ドメイン",
|
||||
"type": "タイプ",
|
||||
|
||||
@@ -261,19 +261,18 @@
|
||||
"backup": "Backup",
|
||||
"backupTitle": "Backup & Restauração",
|
||||
"exportDatabase": "Backup",
|
||||
"exportDatabaseDesc": "Clique para baixar um arquivo .db contendo um backup do seu banco de dados atual para o seu dispositivo.",
|
||||
"exportDatabaseDesc": "Clique para baixar um arquivo .db contendo um backup do seu banco de dados atual para o seu dispositivo. O mesmo arquivo também pode ser restaurado em um painel executando PostgreSQL.",
|
||||
"importDatabase": "Restaurar",
|
||||
"importDatabaseDesc": "Clique para selecionar e enviar um arquivo .db do seu dispositivo para restaurar seu banco de dados a partir de um backup.",
|
||||
"importDatabaseDesc": "Clique para selecionar e enviar um backup .db ou um dump de migração (.dump) do seu dispositivo para restaurar seu banco de dados.",
|
||||
"importDatabaseSuccess": "O banco de dados foi importado com sucesso",
|
||||
"importDatabaseError": "Ocorreu um erro ao importar o banco de dados",
|
||||
"readDatabaseError": "Ocorreu um erro ao ler o banco de dados",
|
||||
"getDatabaseError": "Ocorreu um erro ao recuperar o banco de dados",
|
||||
"getConfigError": "Ocorreu um erro ao recuperar o arquivo de configuração",
|
||||
"backupPostgresNote": "Este painel é executado em PostgreSQL. «Backup» baixa um arquivo pg_dump (.dump) e «Restaurar» o recarrega com pg_restore. O servidor precisa ter as ferramentas cliente do PostgreSQL (pg_dump e pg_restore) instaladas.",
|
||||
"backupPostgresNote": "Este painel é executado em PostgreSQL. «Backup» baixa um arquivo pg_dump (.dump) e «Restaurar» o recarrega com pg_restore. «Restaurar» também aceita um banco de dados SQLite (.db) ou um dump de migração do SQLite e importa seus dados para o PostgreSQL. O servidor precisa ter as ferramentas cliente do PostgreSQL (pg_dump e pg_restore) instaladas.",
|
||||
"exportDatabasePgDesc": "Clique para baixar um dump do PostgreSQL (.dump) do seu banco de dados atual para o seu dispositivo.",
|
||||
"importDatabasePgDesc": "Clique para selecionar e enviar um arquivo .dump para restaurar seu banco de dados PostgreSQL. Isso substitui todos os dados atuais.",
|
||||
"importDatabasePgDesc": "Clique para selecionar e enviar um backup do PostgreSQL (.dump), um banco de dados SQLite (.db) ou um dump de migração do SQLite para restaurar seu banco de dados. Isso substitui todos os dados atuais.",
|
||||
"migrationDownload": "Baixar migração",
|
||||
"migrationDownloadDesc": "Clique para baixar uma exportação portátil .dump (texto SQL) do seu banco de dados SQLite.",
|
||||
"migrationDownloadPgDesc": "Clique para baixar um banco de dados SQLite .db criado a partir dos seus dados do PostgreSQL, pronto para executar este painel no SQLite."
|
||||
},
|
||||
"inbounds": {
|
||||
@@ -1622,6 +1621,8 @@
|
||||
"tag": "Tag",
|
||||
"tagDesc": "Tag Única",
|
||||
"address": "Endereço",
|
||||
"egress": "Egress",
|
||||
"egressHint": "Run an HTTP test to show egress IP and country.",
|
||||
"reverse": "Reverso",
|
||||
"domain": "Domínio",
|
||||
"type": "Tipo",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user