diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index aa68ab6d..7da007d9 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -210,9 +210,9 @@
}
},
"node_modules/@codemirror/view": {
- "version": "6.42.1",
- "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.42.1.tgz",
- "integrity": "sha512-ToN3oFc0nsxNUYVF5P0ztLgbC4UPPjPtA9aKYhkOKQaZASpOUo6ISXyQLP66ctVwlDc+j6Jv0uK5IFALkiXztg==",
+ "version": "6.43.0",
+ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.0.tgz",
+ "integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.6.0",
@@ -610,9 +610,9 @@
}
},
"node_modules/@oxc-project/types": {
- "version": "0.129.0",
- "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.129.0.tgz",
- "integrity": "sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==",
+ "version": "0.130.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz",
+ "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==",
"dev": true,
"license": "MIT",
"funding": {
@@ -620,9 +620,9 @@
}
},
"node_modules/@rolldown/binding-android-arm64": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0.tgz",
- "integrity": "sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz",
+ "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==",
"cpu": [
"arm64"
],
@@ -637,9 +637,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0.tgz",
- "integrity": "sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz",
+ "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==",
"cpu": [
"arm64"
],
@@ -654,9 +654,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0.tgz",
- "integrity": "sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz",
+ "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==",
"cpu": [
"x64"
],
@@ -671,9 +671,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0.tgz",
- "integrity": "sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz",
+ "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==",
"cpu": [
"x64"
],
@@ -688,9 +688,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0.tgz",
- "integrity": "sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz",
+ "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==",
"cpu": [
"arm"
],
@@ -705,9 +705,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0.tgz",
- "integrity": "sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz",
+ "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==",
"cpu": [
"arm64"
],
@@ -725,9 +725,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0.tgz",
- "integrity": "sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz",
+ "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==",
"cpu": [
"arm64"
],
@@ -745,9 +745,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0.tgz",
- "integrity": "sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz",
+ "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==",
"cpu": [
"ppc64"
],
@@ -765,9 +765,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0.tgz",
- "integrity": "sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz",
+ "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==",
"cpu": [
"s390x"
],
@@ -785,9 +785,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0.tgz",
- "integrity": "sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz",
+ "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==",
"cpu": [
"x64"
],
@@ -805,9 +805,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0.tgz",
- "integrity": "sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz",
+ "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==",
"cpu": [
"x64"
],
@@ -825,9 +825,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0.tgz",
- "integrity": "sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz",
+ "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==",
"cpu": [
"arm64"
],
@@ -842,9 +842,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0.tgz",
- "integrity": "sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz",
+ "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==",
"cpu": [
"wasm32"
],
@@ -861,9 +861,9 @@
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0.tgz",
- "integrity": "sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz",
+ "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==",
"cpu": [
"arm64"
],
@@ -878,9 +878,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0.tgz",
- "integrity": "sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz",
+ "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==",
"cpu": [
"x64"
],
@@ -2715,14 +2715,14 @@
"license": "MIT"
},
"node_modules/rolldown": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0.tgz",
- "integrity": "sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
+ "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@oxc-project/types": "=0.129.0",
- "@rolldown/pluginutils": "1.0.0"
+ "@oxc-project/types": "=0.130.0",
+ "@rolldown/pluginutils": "^1.0.0"
},
"bin": {
"rolldown": "bin/cli.mjs"
@@ -2731,27 +2731,27 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
- "@rolldown/binding-android-arm64": "1.0.0",
- "@rolldown/binding-darwin-arm64": "1.0.0",
- "@rolldown/binding-darwin-x64": "1.0.0",
- "@rolldown/binding-freebsd-x64": "1.0.0",
- "@rolldown/binding-linux-arm-gnueabihf": "1.0.0",
- "@rolldown/binding-linux-arm64-gnu": "1.0.0",
- "@rolldown/binding-linux-arm64-musl": "1.0.0",
- "@rolldown/binding-linux-ppc64-gnu": "1.0.0",
- "@rolldown/binding-linux-s390x-gnu": "1.0.0",
- "@rolldown/binding-linux-x64-gnu": "1.0.0",
- "@rolldown/binding-linux-x64-musl": "1.0.0",
- "@rolldown/binding-openharmony-arm64": "1.0.0",
- "@rolldown/binding-wasm32-wasi": "1.0.0",
- "@rolldown/binding-win32-arm64-msvc": "1.0.0",
- "@rolldown/binding-win32-x64-msvc": "1.0.0"
+ "@rolldown/binding-android-arm64": "1.0.1",
+ "@rolldown/binding-darwin-arm64": "1.0.1",
+ "@rolldown/binding-darwin-x64": "1.0.1",
+ "@rolldown/binding-freebsd-x64": "1.0.1",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.0.1",
+ "@rolldown/binding-linux-arm64-gnu": "1.0.1",
+ "@rolldown/binding-linux-arm64-musl": "1.0.1",
+ "@rolldown/binding-linux-ppc64-gnu": "1.0.1",
+ "@rolldown/binding-linux-s390x-gnu": "1.0.1",
+ "@rolldown/binding-linux-x64-gnu": "1.0.1",
+ "@rolldown/binding-linux-x64-musl": "1.0.1",
+ "@rolldown/binding-openharmony-arm64": "1.0.1",
+ "@rolldown/binding-wasm32-wasi": "1.0.1",
+ "@rolldown/binding-win32-arm64-msvc": "1.0.1",
+ "@rolldown/binding-win32-x64-msvc": "1.0.1"
}
},
"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0.tgz",
- "integrity": "sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"dev": true,
"license": "MIT"
},
@@ -2964,16 +2964,16 @@
"license": "MIT"
},
"node_modules/vite": {
- "version": "8.0.12",
- "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.12.tgz",
- "integrity": "sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==",
+ "version": "8.0.13",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
+ "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.14",
- "rolldown": "1.0.0",
+ "rolldown": "1.0.1",
"tinyglobby": "^0.2.16"
},
"bin": {
diff --git a/frontend/src/api/axios-init.js b/frontend/src/api/axios-init.js
index 2ea235c5..3055e883 100644
--- a/frontend/src/api/axios-init.js
+++ b/frontend/src/api/axios-init.js
@@ -51,7 +51,12 @@ export function setupAxios() {
axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
- const basePath = window.X_UI_BASE_PATH;
+ // Read base path from window object or fallback to meta tag (for Cloudflare Rocket Loader compatibility)
+ let basePath = window.X_UI_BASE_PATH;
+ if (!basePath) {
+ const metaTag = document.querySelector('meta[name="base-path"]');
+ basePath = metaTag ? metaTag.getAttribute('content') : null;
+ }
if (typeof basePath === 'string' && basePath !== '' && basePath !== '/') {
axios.defaults.baseURL = basePath;
}
diff --git a/frontend/src/entries/api-docs.js b/frontend/src/entries/api-docs.js
index 852bbc41..c2baff6c 100644
--- a/frontend/src/entries/api-docs.js
+++ b/frontend/src/entries/api-docs.js
@@ -4,7 +4,7 @@ import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import '@/composables/useTheme.js';
-import { i18n } from '@/i18n/index.js';
+import { i18n, readyI18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils';
import ApiDocsPage from '@/pages/api-docs/ApiDocsPage.vue';
@@ -16,4 +16,6 @@ if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
-createApp(ApiDocsPage).use(Antd).use(i18n).mount('#app');
+readyI18n().then(() => {
+ createApp(ApiDocsPage).use(Antd).use(i18n).mount('#app');
+});
diff --git a/frontend/src/entries/inbounds.js b/frontend/src/entries/inbounds.js
index 8342f476..dd9b8170 100644
--- a/frontend/src/entries/inbounds.js
+++ b/frontend/src/entries/inbounds.js
@@ -4,7 +4,7 @@ import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import '@/composables/useTheme.js';
-import { i18n } from '@/i18n/index.js';
+import { i18n, readyI18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils';
import InboundsPage from '@/pages/inbounds/InboundsPage.vue';
@@ -16,4 +16,6 @@ if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
-createApp(InboundsPage).use(Antd).use(i18n).mount('#app');
+readyI18n().then(() => {
+ createApp(InboundsPage).use(Antd).use(i18n).mount('#app');
+});
diff --git a/frontend/src/entries/index.js b/frontend/src/entries/index.js
index 8e14d2ae..33593f31 100644
--- a/frontend/src/entries/index.js
+++ b/frontend/src/entries/index.js
@@ -6,7 +6,7 @@ import { setupAxios } from '@/api/axios-init.js';
// Importing useTheme triggers the boot side-effect that applies the
// stored theme to
/ before Vue mounts.
import '@/composables/useTheme.js';
-import { i18n } from '@/i18n/index.js';
+import { i18n, readyI18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils';
import IndexPage from '@/pages/index/IndexPage.vue';
@@ -18,4 +18,6 @@ if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
-createApp(IndexPage).use(Antd).use(i18n).mount('#app');
+readyI18n().then(() => {
+ createApp(IndexPage).use(Antd).use(i18n).mount('#app');
+});
diff --git a/frontend/src/entries/login.js b/frontend/src/entries/login.js
index 6b8b0fc2..33c1e629 100644
--- a/frontend/src/entries/login.js
+++ b/frontend/src/entries/login.js
@@ -6,18 +6,18 @@ import { setupAxios } from '@/api/axios-init.js';
// Importing this module triggers the boot side-effect that applies the
// stored theme to / before Vue renders anything.
import '@/composables/useTheme.js';
-import { i18n } from '@/i18n/index.js';
+import { i18n, readyI18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils';
import LoginPage from '@/pages/login/LoginPage.vue';
setupAxios();
applyDocumentTitle();
-// Toasts attach to a #message div the page provides — keeps theme
-// styling in sync with the rest of the panel.
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
-createApp(LoginPage).use(Antd).use(i18n).mount('#app');
+readyI18n().then(() => {
+ createApp(LoginPage).use(Antd).use(i18n).mount('#app');
+});
diff --git a/frontend/src/entries/nodes.js b/frontend/src/entries/nodes.js
index 819fe9a9..92e60a15 100644
--- a/frontend/src/entries/nodes.js
+++ b/frontend/src/entries/nodes.js
@@ -4,7 +4,7 @@ import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import '@/composables/useTheme.js';
-import { i18n } from '@/i18n/index.js';
+import { i18n, readyI18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils';
import NodesPage from '@/pages/nodes/NodesPage.vue';
@@ -16,4 +16,6 @@ if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
-createApp(NodesPage).use(Antd).use(i18n).mount('#app');
+readyI18n().then(() => {
+ createApp(NodesPage).use(Antd).use(i18n).mount('#app');
+});
diff --git a/frontend/src/entries/settings.js b/frontend/src/entries/settings.js
index 4a32bb7e..ca3e6f29 100644
--- a/frontend/src/entries/settings.js
+++ b/frontend/src/entries/settings.js
@@ -6,7 +6,7 @@ import { setupAxios } from '@/api/axios-init.js';
// Importing useTheme triggers the boot side-effect that applies the
// stored theme to / before Vue mounts.
import '@/composables/useTheme.js';
-import { i18n } from '@/i18n/index.js';
+import { i18n, readyI18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils';
import SettingsPage from '@/pages/settings/SettingsPage.vue';
@@ -18,4 +18,6 @@ if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
-createApp(SettingsPage).use(Antd).use(i18n).mount('#app');
+readyI18n().then(() => {
+ createApp(SettingsPage).use(Antd).use(i18n).mount('#app');
+});
diff --git a/frontend/src/entries/subpage.js b/frontend/src/entries/subpage.js
index 159aee1b..a2b0d8bb 100644
--- a/frontend/src/entries/subpage.js
+++ b/frontend/src/entries/subpage.js
@@ -7,7 +7,7 @@ import 'ant-design-vue/dist/reset.css';
// with the parsed traffic/quota/expiry view-model and the rendered
// share links — the SPA reads those at mount.
import '@/composables/useTheme.js';
-import { i18n } from '@/i18n/index.js';
+import { i18n, readyI18n } from '@/i18n/index.js';
import SubPage from '@/pages/sub/SubPage.vue';
const messageContainer = document.getElementById('message');
@@ -15,4 +15,6 @@ if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
-createApp(SubPage).use(Antd).use(i18n).mount('#app');
+readyI18n().then(() => {
+ createApp(SubPage).use(Antd).use(i18n).mount('#app');
+});
diff --git a/frontend/src/entries/xray.js b/frontend/src/entries/xray.js
index ba203da0..a5b1ebcd 100644
--- a/frontend/src/entries/xray.js
+++ b/frontend/src/entries/xray.js
@@ -4,7 +4,7 @@ import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import '@/composables/useTheme.js';
-import { i18n } from '@/i18n/index.js';
+import { i18n, readyI18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils';
import XrayPage from '@/pages/xray/XrayPage.vue';
@@ -16,4 +16,6 @@ if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
-createApp(XrayPage).use(Antd).use(i18n).mount('#app');
+readyI18n().then(() => {
+ createApp(XrayPage).use(Antd).use(i18n).mount('#app');
+});
diff --git a/frontend/src/i18n/index.js b/frontend/src/i18n/index.js
index 1e21d215..7b13784c 100644
--- a/frontend/src/i18n/index.js
+++ b/frontend/src/i18n/index.js
@@ -1,93 +1,54 @@
-// vue-i18n setup. Locale files live in web/translation/*.json — the same
-// directory the Go binary embeds, so SPA + Telegram bot + subscription
-// page all read from a single source.
-//
-// Usage in a component:
-// import { useI18n } from 'vue-i18n';
-// const { t } = useI18n();
-// ...
-// {{ t('pages.inbounds.email') }}
-//
-// Or via the global helper exposed on the app:
-// {{ $t('pages.inbounds.email') }}
-//
-// The locale follows the `lang` cookie that LanguageManager already
-// reads/writes — switching language anywhere in the app continues to
-// trigger a full page reload (matches legacy ergonomics), so we don't
-// need a runtime locale switcher here.
-
import { createI18n } from 'vue-i18n';
import { LanguageManager } from '@/utils';
+import enUS from '../../../web/translation/en-US.json';
-// Lazy-loaded locales — Vite splits each one into its own chunk. We
-// eager-load only the active language plus the en-US fallback so the
-// initial page payload stays small (the inbounds bundle was sitting
-// at ~700kB gzipped with all 13 locales eager; now ~480kB).
-//
-// LanguageManager.setLanguage() does a full reload on change, so
-// "lazy" here effectively means "load only what this page needs for
-// its lifetime."
const FALLBACK = 'en-US';
-const lazyModules = import.meta.glob('../../../web/translation/*.json');
-const eagerModules = import.meta.glob('../../../web/translation/*.json', { eager: true });
+const lazyModules = import.meta.glob([
+ '../../../web/translation/*.json',
+ '!../../../web/translation/en-US.json',
+]);
function moduleKeyFor(code) {
return `../../../web/translation/${code}.json`;
}
-// Resolve the active locale via LanguageManager so the cookie set on
-// the legacy panel keeps working after a user upgrades. Falls back
-// to en-US when the cookie names a language we don't have.
let active = LanguageManager.getLanguage();
-if (!Object.prototype.hasOwnProperty.call(lazyModules, moduleKeyFor(active))) {
+if (active !== FALLBACK && !Object.prototype.hasOwnProperty.call(lazyModules, moduleKeyFor(active))) {
active = FALLBACK;
}
-const messages = {};
-// Eagerly include the active locale + the fallback (when distinct)
-// so the very first render has strings ready. Vite still emits these
-// as their own chunks so the user pays for at most two locales.
-for (const code of new Set([active, FALLBACK])) {
- const mod = eagerModules[moduleKeyFor(code)];
- if (mod) messages[code] = mod.default || mod;
-}
-
export const i18n = createI18n({
legacy: false,
- // `composition` mode (legacy: false) so `useI18n()` works in
- //
@@ -1005,7 +1124,7 @@ watch(
+ title="Physical interface for outbound traffic. Use 'auto' to detect; auto-enabled when Auto system routes is set.">
Auto outbounds interface
@@ -1944,20 +2063,48 @@ watch(
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+ Full inbound object with all fields in one editor.
+
+
+
+
+
+ Xray settings block wrapper:
+ { settings: { ... } }.
+
+
+
+
+
+ Xray sniffing block wrapper:
+ { sniffing: { ... } }.
+
+
+
+
+
+ Xray stream block wrapper:
+ { streamSettings: { ... } }.
+
+
+
+
+
+
@@ -2033,6 +2180,66 @@ watch(
margin-top: 4px;
}
+.advanced-shell {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.advanced-panel {
+ padding: 14px;
+ border: 1px solid rgba(128, 128, 128, 0.18);
+ border-radius: 12px;
+ background: rgba(128, 128, 128, 0.04);
+}
+
+.advanced-panel__header {
+ margin-bottom: 12px;
+}
+
+.advanced-panel__title {
+ font-size: 14px;
+ font-weight: 600;
+ line-height: 1.4;
+}
+
+.advanced-panel__subtitle {
+ margin-top: 4px;
+ opacity: 0.7;
+ line-height: 1.5;
+}
+
+.advanced-inner-tabs :deep(.ant-tabs-nav) {
+ margin-bottom: 12px;
+}
+
+.advanced-inner-tabs :deep(.ant-tabs-tab) {
+ padding-inline: 14px;
+}
+
+.advanced-editor-meta {
+ margin-bottom: 10px;
+ opacity: 0.75;
+ line-height: 1.5;
+}
+
+@media (max-width: 768px) {
+ .advanced-panel {
+ padding: 12px;
+ border-radius: 10px;
+ }
+
+ .advanced-inner-tabs :deep(.ant-tabs-tab) {
+ padding-inline: 10px;
+ }
+}
+
+:global(body.dark) .advanced-panel,
+:global(html[data-theme='ultra-dark']) .advanced-panel {
+ border-color: rgba(255, 255, 255, 0.12);
+ background: rgba(255, 255, 255, 0.03);
+}
+
.section-heading {
font-weight: 500;
margin: 12px 0 6px;
diff --git a/frontend/src/pages/index/IndexPage.vue b/frontend/src/pages/index/IndexPage.vue
index 0551c2b6..fe918465 100644
--- a/frontend/src/pages/index/IndexPage.vue
+++ b/frontend/src/pages/index/IndexPage.vue
@@ -1,6 +1,7 @@
@@ -360,8 +373,27 @@ async function openConfig() {
-
+
+
+
+
+
+
+
+
+ config.json
+ Download
+
+
+
+
+
+ Copy
+
+
+
diff --git a/frontend/src/pages/nodes/NodeFormModal.vue b/frontend/src/pages/nodes/NodeFormModal.vue
index 720f4e8b..9c392ffa 100644
--- a/frontend/src/pages/nodes/NodeFormModal.vue
+++ b/frontend/src/pages/nodes/NodeFormModal.vue
@@ -163,9 +163,9 @@ async function onSave() {
-
+
- Enable only for nodes on a private network or VPN.
+ {{ t('pages.nodes.allowPrivateAddressHint') }}
diff --git a/frontend/src/pages/xray/BalancerFormModal.vue b/frontend/src/pages/xray/BalancerFormModal.vue
index 47dd2d41..05aabfeb 100644
--- a/frontend/src/pages/xray/BalancerFormModal.vue
+++ b/frontend/src/pages/xray/BalancerFormModal.vue
@@ -61,6 +61,16 @@ const isValid = computed(
() => !tagEmpty.value && !duplicateTag.value && !emptySelector.value,
);
+const fallbackSupported = computed(
+ () => form.strategy === 'leastPing' || form.strategy === 'leastLoad',
+);
+
+watch(() => form.strategy, (next) => {
+ if (next !== 'leastPing' && next !== 'leastLoad') {
+ form.fallbackTag = '';
+ }
+});
+
const tagValidateStatus = computed(() => {
if (tagEmpty.value) return 'error';
if (duplicateTag.value) return 'warning';
@@ -111,8 +121,9 @@ const okText = computed(() =>
-
-
+
+
{{ tag || `(${t('none')})` }}
diff --git a/frontend/src/pages/xray/BalancersTab.vue b/frontend/src/pages/xray/BalancersTab.vue
index 0cf5b602..255ed108 100644
--- a/frontend/src/pages/xray/BalancersTab.vue
+++ b/frontend/src/pages/xray/BalancersTab.vue
@@ -145,10 +145,11 @@ function syncObservatories() {
}
function buildWireBalancer(form) {
+ const supportsFallback = form.strategy === 'leastPing' || form.strategy === 'leastLoad';
const out = {
tag: form.tag,
selector: [...form.selector],
- fallbackTag: form.fallbackTag,
+ fallbackTag: supportsFallback ? form.fallbackTag : '',
};
if (form.strategy && form.strategy !== 'random') {
out.strategy = { type: form.strategy };
diff --git a/web/controller/dist.go b/web/controller/dist.go
index fd1b35a9..682f0024 100644
--- a/web/controller/dist.go
+++ b/web/controller/dist.go
@@ -56,6 +56,7 @@ func serveDistPage(c *gin.Context, name string) {
csrfToken = ""
}
csrfMeta := []byte(``)
+ basePathMeta := []byte(``)
nonceAttr := ""
if nonce := c.GetString("csp_nonce"); nonce != "" {
@@ -69,6 +70,7 @@ func serveDistPage(c *gin.Context, name string) {
script += `;`
inject := []byte(script)
inject = append(inject, csrfMeta...)
+ inject = append(inject, basePathMeta...)
inject = append(inject, []byte(``)...)
out := bytes.Replace(body, []byte(""), inject, 1)
diff --git a/web/job/node_traffic_sync_job.go b/web/job/node_traffic_sync_job.go
index a978d767..c2f5fa6a 100644
--- a/web/job/node_traffic_sync_job.go
+++ b/web/job/node_traffic_sync_job.go
@@ -87,10 +87,6 @@ func (j *NodeTrafficSyncJob) Run() {
return
}
- online := j.inboundService.GetOnlineClients()
- if online == nil {
- online = []string{}
- }
lastOnline, err := j.inboundService.GetClientsLastOnline()
if err != nil {
logger.Warning("node traffic sync: get last-online failed:", err)
@@ -98,6 +94,13 @@ func (j *NodeTrafficSyncJob) Run() {
if lastOnline == nil {
lastOnline = map[string]int64{}
}
+
+ j.inboundService.RefreshOnlineClientsFromMap(lastOnline)
+
+ online := j.inboundService.GetOnlineClients()
+ if online == nil {
+ online = []string{}
+ }
websocket.BroadcastTraffic(map[string]any{
"onlineClients": online,
"lastOnlineMap": lastOnline,
diff --git a/web/job/xray_traffic_job.go b/web/job/xray_traffic_job.go
index 96539986..7a471b4c 100644
--- a/web/job/xray_traffic_job.go
+++ b/web/job/xray_traffic_job.go
@@ -77,10 +77,6 @@ func (j *XrayTrafficJob) Run() {
// a missing/null onlineClients field as "no update", so without this the
// "everyone went offline" transition was silently dropped — stale online
// users lingered in the list and the online filter kept showing them.
- onlineClients := j.inboundService.GetOnlineClients()
- if onlineClients == nil {
- onlineClients = []string{}
- }
lastOnlineMap, err := j.inboundService.GetClientsLastOnline()
if err != nil {
logger.Warning("get clients last online failed:", err)
@@ -88,6 +84,17 @@ func (j *XrayTrafficJob) Run() {
if lastOnlineMap == nil {
lastOnlineMap = make(map[string]int64)
}
+
+ // Determine online clients from lastOnline timestamps with a 5-second
+ // grace period instead of just the current 5-second traffic poll. This
+ // prevents idle-but-connected clients from randomly disappearing from
+ // the UI between polling windows.
+ j.inboundService.RefreshOnlineClientsFromMap(lastOnlineMap)
+
+ onlineClients := j.inboundService.GetOnlineClients()
+ if onlineClients == nil {
+ onlineClients = []string{}
+ }
websocket.BroadcastTraffic(map[string]any{
"traffics": traffics,
"clientTraffics": clientTraffics,
diff --git a/web/service/inbound.go b/web/service/inbound.go
index 00cb58fe..16bb2528 100644
--- a/web/service/inbound.go
+++ b/web/service/inbound.go
@@ -278,11 +278,31 @@ func (s *InboundService) checkEmailsExistForClients(clients []model.Client) (str
return "", nil
}
+// normalizeStreamSettings clears StreamSettings for protocols that don't use it.
+// Only vmess, vless, trojan, shadowsocks, and hysteria protocols use streamSettings.
+func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) {
+ protocolsWithStream := map[model.Protocol]bool{
+ model.VMESS: true,
+ model.VLESS: true,
+ model.Trojan: true,
+ model.Shadowsocks: true,
+ model.Hysteria: true,
+ model.Hysteria2: true,
+ }
+
+ if !protocolsWithStream[inbound.Protocol] {
+ inbound.StreamSettings = ""
+ }
+}
+
// AddInbound creates a new inbound configuration.
// It validates port uniqueness, client email uniqueness, and required fields,
// then saves the inbound to the database and optionally adds it to the running Xray instance.
// Returns the created inbound, whether Xray needs restart, and any error.
func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
+ // Normalize streamSettings based on protocol
+ s.normalizeStreamSettings(inbound)
+
exist, err := s.checkPortConflict(inbound, 0)
if err != nil {
return inbound, false, err
@@ -501,6 +521,19 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
return true, nil
}
+ // Remote nodes interpret DelInbound as a real row delete (it hits
+ // panel/api/inbounds/del/:id on the remote), so toggling the enable
+ // switch on a remote inbound used to wipe the row entirely (#4402).
+ // PATCH the remote row via UpdateInbound instead — preserves the
+ // settings/client history and just flips the enable flag.
+ if inbound.NodeID != nil {
+ if err := rt.UpdateInbound(context.Background(), inbound, inbound); err != nil {
+ logger.Debug("SetInboundEnable: remote UpdateInbound on", rt.Name(), "failed:", err)
+ return false, err
+ }
+ return false, nil
+ }
+
if err := rt.DelInbound(context.Background(), inbound); err != nil &&
!strings.Contains(err.Error(), "not found") {
logger.Debug("SetInboundEnable: DelInbound on", rt.Name(), "failed:", err)
@@ -510,26 +543,22 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
return needRestart, nil
}
- addTarget := inbound
- if inbound.NodeID == nil {
- runtimeInbound, err := s.buildRuntimeInboundForAPI(db, inbound)
- if err != nil {
- logger.Debug("SetInboundEnable: build runtime config failed:", err)
- return true, nil
- }
- addTarget = runtimeInbound
+ runtimeInbound, err := s.buildRuntimeInboundForAPI(db, inbound)
+ if err != nil {
+ logger.Debug("SetInboundEnable: build runtime config failed:", err)
+ return true, nil
}
- if err := rt.AddInbound(context.Background(), addTarget); err != nil {
+ if err := rt.AddInbound(context.Background(), runtimeInbound); err != nil {
logger.Debug("SetInboundEnable: AddInbound on", rt.Name(), "failed:", err)
- if inbound.NodeID != nil {
- return false, err
- }
needRestart = true
}
return needRestart, nil
}
func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
+ // Normalize streamSettings based on protocol
+ s.normalizeStreamSettings(inbound)
+
exist, err := s.checkPortConflict(inbound, inbound.Id)
if err != nil {
return inbound, false, err
@@ -1516,6 +1545,13 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
const resetGracePeriodMs int64 = 30000
+// onlineGracePeriodMs must comfortably exceed the 5s traffic-poll interval —
+// Xray's stats counters often report a zero delta for an active session across
+// a single poll, so a 5s grace would still drop the client on the next tick.
+// ~4 polls of slack keeps idle-but-connected clients visible without lingering
+// long after a real disconnect.
+const onlineGracePeriodMs int64 = 20000
+
func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnapshot) (bool, error) {
var structuralChange bool
err := submitTrafficWrite(func() error {
@@ -1857,15 +1893,9 @@ func (s *InboundService) addInboundTraffic(tx *gorm.DB, traffics []*xray.Traffic
func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTraffic) (err error) {
if len(traffics) == 0 {
- // Empty onlineUsers
- if p != nil {
- p.SetOnlineClients(make([]string, 0))
- }
return nil
}
- onlineClients := make([]string, 0)
-
emails := make([]string, 0, len(traffics))
for _, traffic := range traffics {
emails = append(emails, traffic.Email)
@@ -1908,14 +1938,10 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
dbClientTraffics[dbTraffic_index].Down += t.Down
dbClientTraffics[dbTraffic_index].AllTime += t.Up + t.Down
if t.Up+t.Down > 0 {
- onlineClients = append(onlineClients, t.Email)
dbClientTraffics[dbTraffic_index].LastOnline = now
}
}
- // Set onlineUsers
- p.SetOnlineClients(onlineClients)
-
err = tx.Save(dbClientTraffics).Error
if err != nil {
logger.Warning("AddClientTraffic update data ", err)
@@ -3741,6 +3767,19 @@ func (s *InboundService) GetClientsLastOnline() (map[string]int64, error) {
return result, nil
}
+func (s *InboundService) RefreshOnlineClientsFromMap(lastOnlineMap map[string]int64) {
+ now := time.Now().UnixMilli()
+ newOnlineClients := make([]string, 0, len(lastOnlineMap))
+ for email, lastOnline := range lastOnlineMap {
+ if now-lastOnline < onlineGracePeriodMs {
+ newOnlineClients = append(newOnlineClients, email)
+ }
+ }
+ if p != nil {
+ p.SetOnlineClients(newOnlineClients)
+ }
+}
+
func (s *InboundService) FilterAndSortClientEmails(emails []string) ([]string, []string, error) {
db := database.GetDB()
diff --git a/web/service/outbound.go b/web/service/outbound.go
index 4cef5247..7a56dc7d 100644
--- a/web/service/outbound.go
+++ b/web/service/outbound.go
@@ -190,7 +190,7 @@ func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundRes
wg.Add(1)
go func(i int) {
defer wg.Done()
- results[i] = probeTCPEndpoint(endpoints[i], 5*time.Second)
+ results[i] = probeEndpoint(endpoints[i], 5*time.Second)
}(i)
}
wg.Wait()
@@ -207,7 +207,11 @@ func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundRes
}
}
- out := &TestOutboundResult{Mode: "tcp", Endpoints: results}
+ mode := "tcp"
+ if endpoints[0].Network == "udp" {
+ mode = "udp"
+ }
+ out := &TestOutboundResult{Mode: mode, Endpoints: results}
if bestDelay >= 0 {
out.Success = true
out.Delay = bestDelay
@@ -220,6 +224,22 @@ func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundRes
return out, nil
}
+// outboundEndpoint is a host:port plus the transport its proxy actually
+// listens on. WireGuard (and WARP, which is WireGuard) is UDP-only, so a
+// TCP dial to its peer endpoint always times out — the probe must match
+// the transport of the outbound being tested.
+type outboundEndpoint struct {
+ Address string
+ Network string
+}
+
+func probeEndpoint(ep outboundEndpoint, timeout time.Duration) TestEndpointResult {
+ if ep.Network == "udp" {
+ return probeUDPEndpoint(ep.Address, timeout)
+ }
+ return probeTCPEndpoint(ep.Address, timeout)
+}
+
func probeTCPEndpoint(endpoint string, timeout time.Duration) TestEndpointResult {
r := TestEndpointResult{Address: endpoint}
start := time.Now()
@@ -234,18 +254,69 @@ func probeTCPEndpoint(endpoint string, timeout time.Duration) TestEndpointResult
return r
}
-func extractOutboundEndpoints(ob map[string]any) []string {
+// probeUDPEndpoint sends a single byte and waits briefly for a reply or
+// an ICMP-driven error. WireGuard won't answer an unauthenticated byte,
+// so a read timeout is the normal "endpoint reachable" outcome; a
+// concrete error (e.g. ECONNREFUSED, "host unreachable") fails the probe.
+func probeUDPEndpoint(endpoint string, timeout time.Duration) TestEndpointResult {
+ r := TestEndpointResult{Address: endpoint}
+ start := time.Now()
+ conn, err := net.DialTimeout("udp", endpoint, timeout)
+ if err != nil {
+ r.Delay = time.Since(start).Milliseconds()
+ r.Error = err.Error()
+ return r
+ }
+ defer conn.Close()
+
+ if _, werr := conn.Write([]byte{0}); werr != nil {
+ r.Delay = time.Since(start).Milliseconds()
+ r.Error = werr.Error()
+ return r
+ }
+
+ _ = conn.SetReadDeadline(time.Now().Add(timeout))
+ buf := make([]byte, 64)
+ _, rerr := conn.Read(buf)
+ r.Delay = time.Since(start).Milliseconds()
+ if rerr != nil {
+ if nerr, ok := rerr.(net.Error); ok && nerr.Timeout() {
+ r.Success = true
+ return r
+ }
+ r.Error = rerr.Error()
+ return r
+ }
+ r.Success = true
+ return r
+}
+
+func extractOutboundEndpoints(ob map[string]any) []outboundEndpoint {
protocol, _ := ob["protocol"].(string)
settings, _ := ob["settings"].(map[string]any)
if settings == nil {
return nil
}
- var out []string
+
+ // Hysteria (and hysteria2 over trojan) is QUIC/UDP. Detect it via the
+ // outer protocol or via streamSettings.network so trojan-with-hysteria2
+ // transport gets probed over UDP too. kcp and quic are also UDP-based.
+ network := "tcp"
+ if protocol == "hysteria" || protocol == "wireguard" {
+ network = "udp"
+ }
+ if stream, ok := ob["streamSettings"].(map[string]any); ok {
+ if n, _ := stream["network"].(string); n == "hysteria" || n == "kcp" || n == "quic" {
+ network = "udp"
+ }
+ }
+
+ var out []outboundEndpoint
addServer := func(addr any, port any) {
host, _ := addr.(string)
p := numAsInt(port)
if host != "" && p > 0 {
- out = append(out, fmt.Sprintf("%s:%d", host, p))
+ out = append(out, outboundEndpoint{Address: fmt.Sprintf("%s:%d", host, p), Network: network})
}
}
switch protocol {
@@ -259,6 +330,8 @@ func extractOutboundEndpoints(ob map[string]any) []string {
}
case "vless":
addServer(settings["address"], settings["port"])
+ case "hysteria":
+ addServer(settings["address"], settings["port"])
case "trojan", "shadowsocks", "http", "socks":
if servers, ok := settings["servers"].([]any); ok {
for _, sv := range servers {
@@ -272,7 +345,7 @@ func extractOutboundEndpoints(ob map[string]any) []string {
for _, p := range peers {
if pm, ok := p.(map[string]any); ok {
if ep, _ := pm["endpoint"].(string); ep != "" {
- out = append(out, ep)
+ out = append(out, outboundEndpoint{Address: ep, Network: network})
}
}
}
diff --git a/web/service/tgbot.go b/web/service/tgbot.go
index 3d737c70..179c082f 100644
--- a/web/service/tgbot.go
+++ b/web/service/tgbot.go
@@ -1398,6 +1398,25 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
return
}
+ t.addClient(callbackQuery.Message.GetChat().ID, message_text, messageId)
+ t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation"))
+ case "add_client_set_flow":
+ if dataArray[1] == "none" {
+ client_Flow = ""
+ } else {
+ client_Flow = dataArray[1]
+ }
+ messageId := callbackQuery.Message.GetMessageID()
+ inbound, err := t.inboundService.GetInbound(receiver_inbound_ID)
+ if err != nil {
+ t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
+ return
+ }
+ message_text, err := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol)
+ if err != nil {
+ t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
+ return
+ }
t.addClient(callbackQuery.Message.GetChat().ID, message_text, messageId)
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation"))
case "add_client_ip_limit_in":
@@ -1865,6 +1884,22 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
),
)
t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard)
+ case "add_client_ch_default_flow":
+ inlineKeyboard := tu.InlineKeyboard(
+ tu.InlineKeyboardRow(
+ tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("add_client_default_traffic_exp")),
+ ),
+ tu.InlineKeyboardRow(
+ tu.InlineKeyboardButton("None").WithCallbackData(t.encodeQuery("add_client_set_flow none")),
+ ),
+ tu.InlineKeyboardRow(
+ tu.InlineKeyboardButton("xtls-rprx-vision").WithCallbackData(t.encodeQuery("add_client_set_flow xtls-rprx-vision")),
+ ),
+ tu.InlineKeyboardRow(
+ tu.InlineKeyboardButton("xtls-rprx-vision-udp443").WithCallbackData(t.encodeQuery("add_client_set_flow xtls-rprx-vision-udp443")),
+ ),
+ )
+ t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard)
case "add_client_ch_default_ip_limit":
inlineKeyboard := tu.InlineKeyboard(
tu.InlineKeyboardRow(
@@ -3345,6 +3380,25 @@ func (t *Tgbot) getCommonClientButtons() [][]telego.InlineKeyboardButton {
}
}
+// inboundCanEnableTlsFlow mirrors Inbound.canEnableTlsFlow() from the frontend
+// model: xtls-rprx-vision is only valid on VLESS-over-TCP with TLS or Reality.
+func inboundCanEnableTlsFlow(ib *model.Inbound) bool {
+ if ib == nil || ib.Protocol != model.VLESS {
+ return false
+ }
+ var stream struct {
+ Network string `json:"network"`
+ Security string `json:"security"`
+ }
+ if err := json.Unmarshal([]byte(ib.StreamSettings), &stream); err != nil {
+ return false
+ }
+ if stream.Network != "tcp" {
+ return false
+ }
+ return stream.Security == "tls" || stream.Security == "reality"
+}
+
// addClient handles the process of adding a new client to an inbound.
func (t *Tgbot) addClient(chatId int64, msg string, messageID ...int) {
inbound, err := t.inboundService.GetInbound(receiver_inbound_ID)
@@ -3357,13 +3411,31 @@ func (t *Tgbot) addClient(chatId int64, msg string, messageID ...int) {
var protocolRows [][]telego.InlineKeyboardButton
switch protocol {
- case model.VMESS, model.VLESS:
+ case model.VMESS:
protocolRows = [][]telego.InlineKeyboardButton{
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_id")).WithCallbackData("add_client_ch_default_id"),
),
}
+ case model.VLESS:
+ protocolRows = [][]telego.InlineKeyboardButton{
+ tu.InlineKeyboardRow(
+ tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"),
+ tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_id")).WithCallbackData("add_client_ch_default_id"),
+ ),
+ }
+ if inboundCanEnableTlsFlow(inbound) {
+ flowLabel := t.I18nBot("tgbot.buttons.change_flow")
+ if client_Flow != "" {
+ flowLabel = flowLabel + ": " + client_Flow
+ }
+ protocolRows = append(protocolRows, tu.InlineKeyboardRow(
+ tu.InlineKeyboardButton(flowLabel).WithCallbackData("add_client_ch_default_flow"),
+ ))
+ } else if client_Flow != "" {
+ client_Flow = ""
+ }
case model.Trojan:
protocolRows = [][]telego.InlineKeyboardButton{
tu.InlineKeyboardRow(
diff --git a/web/translation/ar-EG.json b/web/translation/ar-EG.json
index e73eee06..ecf1c19d 100644
--- a/web/translation/ar-EG.json
+++ b/web/translation/ar-EG.json
@@ -418,6 +418,8 @@
"apiTokenHint": "البانل البعيد بيعرض توكن API بتاعه في الإعدادات → توكن API.",
"regenerate": "تجديد التوكن",
"regenerateConfirm": "تجديد التوكن هيلغي التوكن الحالي. أي بانل مركزي بيستخدمه هيفقد الصلاحية لحد ما تحدّث التوكن. تكمّل؟",
+ "allowPrivateAddress": "السماح بالعنوان الخاص",
+ "allowPrivateAddressHint": "التفعيل فقط للعقد على شبكة خاصة أو VPN.",
"enable": "مفعل",
"status": "الحالة",
"cpu": "المعالج",
@@ -950,6 +952,7 @@
"change_password": "⚙️🔑 كلمة السر",
"change_email": "⚙️📧 البريد الإلكتروني",
"change_comment": "⚙️💬 تعليق",
+ "change_flow": "⚙️🚦 التدفق",
"ResetAllTraffics": "إعادة ضبط جميع الترافيك",
"SortedTrafficUsageReport": "تقرير استخدام الترافيك المرتب"
},
diff --git a/web/translation/en-US.json b/web/translation/en-US.json
index 1e8d01c6..eedd3454 100644
--- a/web/translation/en-US.json
+++ b/web/translation/en-US.json
@@ -418,6 +418,8 @@
"apiTokenHint": "The remote panel exposes its API token under Settings → API Token.",
"regenerate": "Regenerate Token",
"regenerateConfirm": "Regenerating invalidates the current token. Any central panel using it will lose access until updated. Continue?",
+ "allowPrivateAddress": "Allow private address",
+ "allowPrivateAddressHint": "Enable only for nodes on a private network or VPN.",
"enable": "Enabled",
"status": "Status",
"cpu": "CPU",
@@ -950,6 +952,7 @@
"change_password": "⚙️🔑 Password",
"change_email": "⚙️📧 Email",
"change_comment": "⚙️💬 Comment",
+ "change_flow": "⚙️🚦 Flow",
"ResetAllTraffics": "Reset All Traffics",
"SortedTrafficUsageReport": "Sorted Traffic Usage Report"
},
diff --git a/web/translation/es-ES.json b/web/translation/es-ES.json
index c02b4de4..34af89ca 100644
--- a/web/translation/es-ES.json
+++ b/web/translation/es-ES.json
@@ -418,6 +418,8 @@
"apiTokenHint": "El panel remoto expone su token de API en Configuración → Token de API.",
"regenerate": "Regenerar token",
"regenerateConfirm": "Regenerar invalida el token actual. Cualquier panel central que lo use perderá el acceso hasta que se actualice. ¿Continuar?",
+ "allowPrivateAddress": "Permitir dirección privada",
+ "allowPrivateAddressHint": "Habilitar solo para nodos en una red privada o VPN.",
"enable": "Habilitado",
"status": "Estado",
"cpu": "CPU",
@@ -950,6 +952,7 @@
"change_password": "⚙️🔑 Contraseña",
"change_email": "⚙️📧 Correo electrónico",
"change_comment": "⚙️💬 Comentario",
+ "change_flow": "⚙️🚦 Flujo",
"ResetAllTraffics": "Reiniciar todo el tráfico",
"SortedTrafficUsageReport": "Informe de uso de tráfico ordenado"
},
diff --git a/web/translation/fa-IR.json b/web/translation/fa-IR.json
index f2052f66..fd948788 100644
--- a/web/translation/fa-IR.json
+++ b/web/translation/fa-IR.json
@@ -418,6 +418,8 @@
"apiTokenHint": "پنل ریموت توکن API خودش را در بخش تنظیمات → توکن API نمایش میدهد.",
"regenerate": "تولید مجدد توکن",
"regenerateConfirm": "تولید مجدد، توکن فعلی را باطل میکند. هر پنل مرکزیای که از این توکن استفاده میکند تا زمان بهروزرسانی، دسترسیاش قطع میشود. ادامه میدهید؟",
+ "allowPrivateAddress": "اجازه آدرس خصوصی",
+ "allowPrivateAddressHint": "فقط برای نودهای روی شبکه خصوصی یا VPN فعال شود.",
"enable": "فعال",
"status": "وضعیت",
"cpu": "پردازنده",
@@ -950,6 +952,7 @@
"change_password": "⚙️🔑 گذرواژه",
"change_email": "⚙️📧 ایمیل",
"change_comment": "⚙️💬 نظر",
+ "change_flow": "⚙️🚦 جریان",
"ResetAllTraffics": "بازنشانی همه ترافیکها",
"SortedTrafficUsageReport": "گزارش استفاده از ترافیک مرتبشده"
},
diff --git a/web/translation/id-ID.json b/web/translation/id-ID.json
index 095984e3..b8f44ac6 100644
--- a/web/translation/id-ID.json
+++ b/web/translation/id-ID.json
@@ -418,6 +418,8 @@
"apiTokenHint": "Panel jarak jauh menampilkan token API-nya di Pengaturan → Token API.",
"regenerate": "Buat Ulang Token",
"regenerateConfirm": "Membuat ulang akan membatalkan token saat ini. Setiap panel pusat yang menggunakannya akan kehilangan akses sampai diperbarui. Lanjutkan?",
+ "allowPrivateAddress": "Izinkan alamat pribadi",
+ "allowPrivateAddressHint": "Aktifkan hanya untuk node di jaringan pribadi atau VPN.",
"enable": "Aktif",
"status": "Status",
"cpu": "CPU",
@@ -950,6 +952,7 @@
"change_password": "⚙️🔑 Kata Sandi",
"change_email": "⚙️📧 Email",
"change_comment": "⚙️💬 Komentar",
+ "change_flow": "⚙️🚦 Flow",
"ResetAllTraffics": "Reset Semua Lalu Lintas",
"SortedTrafficUsageReport": "Laporan Penggunaan Lalu Lintas yang Terurut"
},
diff --git a/web/translation/ja-JP.json b/web/translation/ja-JP.json
index 93c2dbbf..630f5623 100644
--- a/web/translation/ja-JP.json
+++ b/web/translation/ja-JP.json
@@ -418,6 +418,8 @@
"apiTokenHint": "リモートパネルでは、設定 → APIトークン でAPIトークンを確認できます。",
"regenerate": "トークンを再生成",
"regenerateConfirm": "再生成すると現在のトークンは無効になります。これを使用しているすべての中央パネルは更新されるまでアクセスできなくなります。続行しますか?",
+ "allowPrivateAddress": "プライベートアドレスを許可",
+ "allowPrivateAddressHint": "プライベートネットワークまたはVPN上のノードにのみ有効にします。",
"enable": "有効",
"status": "ステータス",
"cpu": "CPU",
@@ -950,6 +952,7 @@
"change_password": "⚙️🔑 パスワード",
"change_email": "⚙️📧 メールアドレス",
"change_comment": "⚙️💬 コメント",
+ "change_flow": "⚙️🚦 フロー",
"ResetAllTraffics": "すべてのトラフィックをリセット",
"SortedTrafficUsageReport": "ソートされたトラフィック使用レポート"
},
diff --git a/web/translation/pt-BR.json b/web/translation/pt-BR.json
index fa56b477..9f1b67d7 100644
--- a/web/translation/pt-BR.json
+++ b/web/translation/pt-BR.json
@@ -418,6 +418,8 @@
"apiTokenHint": "O painel remoto exibe o token da API em Configurações → Token da API.",
"regenerate": "Regenerar token",
"regenerateConfirm": "Regenerar invalida o token atual. Qualquer painel central que o utilize perderá acesso até ser atualizado. Continuar?",
+ "allowPrivateAddress": "Permitir endereço privado",
+ "allowPrivateAddressHint": "Ativar apenas para nós em uma rede privada ou VPN.",
"enable": "Ativado",
"status": "Status",
"cpu": "CPU",
@@ -950,6 +952,7 @@
"change_password": "⚙️🔑 Senha",
"change_email": "⚙️📧 E-mail",
"change_comment": "⚙️💬 Comentário",
+ "change_flow": "⚙️🚦 Fluxo",
"ResetAllTraffics": "Redefinir Todo o Tráfego",
"SortedTrafficUsageReport": "Relatório de Uso de Tráfego Ordenado"
},
diff --git a/web/translation/ru-RU.json b/web/translation/ru-RU.json
index f6f59287..f7ddafa6 100644
--- a/web/translation/ru-RU.json
+++ b/web/translation/ru-RU.json
@@ -418,6 +418,8 @@
"apiTokenHint": "Удалённая панель показывает свой токен API в разделе Настройки → Токен API.",
"regenerate": "Сгенерировать токен заново",
"regenerateConfirm": "Повторная генерация аннулирует текущий токен. Любая центральная панель, использующая его, потеряет доступ до обновления. Продолжить?",
+ "allowPrivateAddress": "Разрешить частный адрес",
+ "allowPrivateAddressHint": "Включить только для узлов в частной сети или VPN.",
"enable": "Включён",
"status": "Статус",
"cpu": "CPU",
@@ -950,6 +952,7 @@
"change_password": "⚙️🔑 Пароль",
"change_email": "⚙️📧 Email",
"change_comment": "⚙️💬 Комментарий",
+ "change_flow": "⚙️🚦 Поток",
"ResetAllTraffics": "Сбросить весь трафик",
"SortedTrafficUsageReport": "Отсортированный отчет об использовании трафика"
},
diff --git a/web/translation/tr-TR.json b/web/translation/tr-TR.json
index 84064211..a8dc2c3c 100644
--- a/web/translation/tr-TR.json
+++ b/web/translation/tr-TR.json
@@ -418,6 +418,8 @@
"apiTokenHint": "Uzak panel API token'ını Ayarlar → API Token altında gösterir.",
"regenerate": "Token'ı Yeniden Oluştur",
"regenerateConfirm": "Yeniden oluşturmak mevcut token'ı geçersiz kılar. Onu kullanan tüm merkezi paneller, güncellenene kadar erişimini kaybeder. Devam edilsin mi?",
+ "allowPrivateAddress": "Özel adrese izin ver",
+ "allowPrivateAddressHint": "Yalnızca özel ağ veya VPN üzerindeki düğümler için etkinleştir.",
"enable": "Etkin",
"status": "Durum",
"cpu": "CPU",
@@ -950,6 +952,7 @@
"change_password": "⚙️🔑 Şifre",
"change_email": "⚙️📧 E-posta",
"change_comment": "⚙️💬 Yorum",
+ "change_flow": "⚙️🚦 Akış",
"ResetAllTraffics": "Tüm Trafikleri Sıfırla",
"SortedTrafficUsageReport": "Sıralı Trafik Kullanım Raporu"
},
diff --git a/web/translation/uk-UA.json b/web/translation/uk-UA.json
index 150decb5..417866d1 100644
--- a/web/translation/uk-UA.json
+++ b/web/translation/uk-UA.json
@@ -418,6 +418,8 @@
"apiTokenHint": "Віддалена панель показує свій токен API в Налаштуваннях → Токен API.",
"regenerate": "Перегенерувати токен",
"regenerateConfirm": "Перегенерація скасовує поточний токен. Будь-яка центральна панель, що його використовує, втратить доступ до оновлення. Продовжити?",
+ "allowPrivateAddress": "Дозволити приватну адресу",
+ "allowPrivateAddressHint": "Увімкнути лише для вузлів у приватній мережі або VPN.",
"enable": "Увімкнено",
"status": "Статус",
"cpu": "CPU",
@@ -950,6 +952,7 @@
"change_password": "⚙️🔑 Пароль",
"change_email": "⚙️📧 Електронна пошта",
"change_comment": "⚙️💬 Коментар",
+ "change_flow": "⚙️🚦 Потік",
"ResetAllTraffics": "Скинути весь трафік",
"SortedTrafficUsageReport": "Відсортований звіт про використання трафіку"
},
diff --git a/web/translation/vi-VN.json b/web/translation/vi-VN.json
index 9eaef4f2..b292c058 100644
--- a/web/translation/vi-VN.json
+++ b/web/translation/vi-VN.json
@@ -418,6 +418,8 @@
"apiTokenHint": "Panel từ xa hiển thị token API tại Cài đặt → Token API.",
"regenerate": "Tạo lại token",
"regenerateConfirm": "Tạo lại sẽ vô hiệu hóa token hiện tại. Mọi panel trung tâm dùng nó sẽ mất quyền truy cập cho đến khi được cập nhật. Tiếp tục?",
+ "allowPrivateAddress": "Cho phép địa chỉ riêng",
+ "allowPrivateAddressHint": "Chỉ bật cho các nút trên mạng riêng hoặc VPN.",
"enable": "Kích hoạt",
"status": "Trạng thái",
"cpu": "CPU",
@@ -950,6 +952,7 @@
"change_password": "⚙️🔑 Mật Khẩu",
"change_email": "⚙️📧 Email",
"change_comment": "⚙️💬 Bình Luận",
+ "change_flow": "⚙️🚦 Flow",
"ResetAllTraffics": "Đặt lại tất cả lưu lượng",
"SortedTrafficUsageReport": "Báo cáo sử dụng lưu lượng đã sắp xếp"
},
diff --git a/web/translation/zh-CN.json b/web/translation/zh-CN.json
index beb78ccb..3f648cb2 100644
--- a/web/translation/zh-CN.json
+++ b/web/translation/zh-CN.json
@@ -418,6 +418,8 @@
"apiTokenHint": "远程面板在 设置 → API 令牌 中显示其 API 令牌。",
"regenerate": "重新生成令牌",
"regenerateConfirm": "重新生成会使当前令牌失效。任何使用该令牌的中央面板都会失去访问权限,直至更新。是否继续?",
+ "allowPrivateAddress": "允许私有地址",
+ "allowPrivateAddressHint": "仅对私有网络或VPN上的节点启用。",
"enable": "已启用",
"status": "状态",
"cpu": "CPU",
@@ -950,6 +952,7 @@
"change_password": "⚙️🔑 密码",
"change_email": "⚙️📧 邮箱",
"change_comment": "⚙️💬 评论",
+ "change_flow": "⚙️🚦 流控",
"ResetAllTraffics": "重置所有流量",
"SortedTrafficUsageReport": "排序的流量使用报告"
},
diff --git a/web/translation/zh-TW.json b/web/translation/zh-TW.json
index 36b60a67..a28c0e0f 100644
--- a/web/translation/zh-TW.json
+++ b/web/translation/zh-TW.json
@@ -418,6 +418,8 @@
"apiTokenHint": "遠端面板在 設定 → API 權杖 中顯示其 API 權杖。",
"regenerate": "重新產生權杖",
"regenerateConfirm": "重新產生會使目前的權杖失效。任何使用該權杖的中央面板將失去存取權,直到更新為止。是否繼續?",
+ "allowPrivateAddress": "允許私有地址",
+ "allowPrivateAddressHint": "僅對私有網路或VPN上的節點啟用。",
"enable": "已啟用",
"status": "狀態",
"cpu": "CPU",
@@ -950,6 +952,7 @@
"change_password": "⚙️🔑 密碼",
"change_email": "⚙️📧 電子郵件",
"change_comment": "⚙️💬 評論",
+ "change_flow": "⚙️🚦 流控",
"ResetAllTraffics": "重設所有流量",
"SortedTrafficUsageReport": "排序過的流量使用報告"
},
diff --git a/xray/config.go b/xray/config.go
index 35ebfd7d..0bd7999c 100644
--- a/xray/config.go
+++ b/xray/config.go
@@ -11,17 +11,17 @@ import (
type Config struct {
LogConfig json_util.RawMessage `json:"log"`
RouterConfig json_util.RawMessage `json:"routing"`
- DNSConfig json_util.RawMessage `json:"dns"`
+ DNSConfig json_util.RawMessage `json:"dns,omitempty"`
InboundConfigs []InboundConfig `json:"inbounds"`
OutboundConfigs json_util.RawMessage `json:"outbounds"`
- Transport json_util.RawMessage `json:"transport"`
+ Transport json_util.RawMessage `json:"transport,omitempty"`
Policy json_util.RawMessage `json:"policy"`
API json_util.RawMessage `json:"api"`
Stats json_util.RawMessage `json:"stats"`
- Reverse json_util.RawMessage `json:"reverse"`
- FakeDNS json_util.RawMessage `json:"fakedns"`
- Observatory json_util.RawMessage `json:"observatory"`
- BurstObservatory json_util.RawMessage `json:"burstObservatory"`
+ Reverse json_util.RawMessage `json:"reverse,omitempty"`
+ FakeDNS json_util.RawMessage `json:"fakedns,omitempty"`
+ Observatory json_util.RawMessage `json:"observatory,omitempty"`
+ BurstObservatory json_util.RawMessage `json:"burstObservatory,omitempty"`
Metrics json_util.RawMessage `json:"metrics"`
}
diff --git a/xray/inbound.go b/xray/inbound.go
index d69e3afc..7cafa87b 100644
--- a/xray/inbound.go
+++ b/xray/inbound.go
@@ -13,9 +13,9 @@ type InboundConfig struct {
Port int `json:"port"`
Protocol string `json:"protocol"`
Settings json_util.RawMessage `json:"settings"`
- StreamSettings json_util.RawMessage `json:"streamSettings"`
+ StreamSettings json_util.RawMessage `json:"streamSettings,omitempty"`
Tag string `json:"tag"`
- Sniffing json_util.RawMessage `json:"sniffing"`
+ Sniffing json_util.RawMessage `json:"sniffing,omitempty"`
}
// Equals compares two InboundConfig instances for deep equality.