mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-18 10:17:36 +07:00
Compare commits
8 Commits
7fe082a7f1
...
f3eba04ed8
| Author | SHA1 | Date | |
|---|---|---|---|
| f3eba04ed8 | |||
| 9385b6c609 | |||
| d882d6aa74 | |||
| bbab83db17 | |||
| dc781b28c4 | |||
| 5b8504c756 | |||
| c1fdcd98d2 | |||
| eec030f86f |
2
.github/workflows/codeql.yml
vendored
2
.github/workflows/codeql.yml
vendored
@ -51,7 +51,7 @@ jobs:
|
||||
if: matrix.language == 'go'
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
node-version-file: .nvmrc
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@ -59,7 +59,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
node-version-file: .nvmrc
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
@ -210,7 +210,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
node-version-file: .nvmrc
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
|
||||
102
docs/real-client-ip.md
Normal file
102
docs/real-client-ip.md
Normal file
@ -0,0 +1,102 @@
|
||||
# Capturing the Real Client IP
|
||||
|
||||
When an Xray inbound sits behind an intermediary — a CDN like Cloudflare, an L4 tunnel/relay,
|
||||
or another panel — the IP that Xray sees is the **intermediary's** address, not the visitor's.
|
||||
That intermediary IP is what shows up in the panel's online/IP view and what the per-client
|
||||
**IP limit** counts against, which makes both useless behind a proxy.
|
||||
|
||||
Xray-core can recover the real visitor IP. 3x-ui exposes the two mechanisms in the inbound form
|
||||
and feeds the recovered IP into the same pipeline that drives IP-limit enforcement, the online
|
||||
list, and multi-node sync — so once it is set, everything downstream just works.
|
||||
|
||||
## Where to set it
|
||||
|
||||
Open an inbound → **Transport / Stream Settings** → enable **Sockopt** → use the
|
||||
**Real client IP** preset selector:
|
||||
|
||||
| Preset | What it does | Use for |
|
||||
|---|---|---|
|
||||
| **Off / direct** | Clears both fields. | Inbound reachable directly by clients. |
|
||||
| **Cloudflare CDN** | Sets `sockopt.trustedXForwardedFor = ["CF-Connecting-IP"]`. | WebSocket / HTTPUpgrade / XHTTP behind Cloudflare's CDN (orange cloud). |
|
||||
| **L4 relay / Spectrum (PROXY)** | Sets `acceptProxyProtocol = true`. | An L4 tunnel/relay in front, or Cloudflare **Spectrum**. |
|
||||
|
||||
The raw `Proxy Protocol` switch and `Trusted X-Forwarded-For` list stay visible below the preset
|
||||
selector for manual / advanced tuning — the presets just fill them in for you.
|
||||
|
||||
## Scenario 1 — Cloudflare CDN
|
||||
|
||||
Cloudflare's CDN (the orange cloud) forwards the visitor's IP in the `CF-Connecting-IP` request
|
||||
header. Xray reads it when the transport is **WebSocket**, **HTTPUpgrade**, or **XHTTP** and
|
||||
the header name is listed in `sockopt.trustedXForwardedFor`.
|
||||
|
||||
```json
|
||||
"streamSettings": {
|
||||
"network": "ws",
|
||||
"sockopt": { "trustedXForwardedFor": ["CF-Connecting-IP"] }
|
||||
}
|
||||
```
|
||||
|
||||
Pick the **Cloudflare CDN** preset. You can add `X-Real-IP`, `True-Client-IP`, or `X-Client-IP`
|
||||
to the list if a different upstream uses those.
|
||||
|
||||
> This is **not** the same as Cloudflare Spectrum. The free/CDN tier forwards HTTP headers — use
|
||||
> this scenario. Spectrum (a TCP/L4 product) can send the PROXY protocol — use Scenario 2.
|
||||
|
||||
## Scenario 2 — L4 tunnel / relay or Cloudflare Spectrum (PROXY protocol)
|
||||
|
||||
For a TCP-level front (HAProxy, gost, nginx `stream`, an Xray dokodemo-door relay, or Cloudflare
|
||||
Spectrum), the real IP is carried in the **PROXY protocol** header. Enable
|
||||
`acceptProxyProtocol` and make sure the **upstream emits PROXY protocol** — otherwise the
|
||||
connection will fail.
|
||||
|
||||
```json
|
||||
"streamSettings": {
|
||||
"network": "tcp",
|
||||
"sockopt": { "acceptProxyProtocol": true }
|
||||
}
|
||||
```
|
||||
|
||||
Pick the **L4 relay / Spectrum (PROXY)** preset. Works on TCP/RAW, WebSocket, HTTPUpgrade, gRPC
|
||||
and XHTTP; **not** on mKCP. The front must be configured to send the header, e.g.:
|
||||
|
||||
- **HAProxy**: `server backend 127.0.0.1:443 send-proxy` (or `send-proxy-v2`).
|
||||
- **nginx** (`stream {}` block): `proxy_protocol on;` on the `server`, and on the upstream side
|
||||
`proxy_protocol on;` in the `server` that connects to Xray.
|
||||
|
||||
## Transport support matrix
|
||||
|
||||
| Mechanism | TCP/RAW | mKCP | WebSocket | gRPC | HTTPUpgrade | XHTTP |
|
||||
|---|:--:|:--:|:--:|:--:|:--:|:--:|
|
||||
| `trustedXForwardedFor` (header) | – | – | ✅ | – | ✅ | ✅ |
|
||||
| `acceptProxyProtocol` (PROXY) | ✅ | – | ✅ | ✅ | ✅ | ✅ |
|
||||
|
||||
The form shows a warning when you select a preset that the current transport cannot honor.
|
||||
|
||||
> **Use one, not both.** `acceptProxyProtocol` and `trustedXForwardedFor` are independent — the
|
||||
> first reads the real IP from the L4 PROXY header, the second from an HTTP request header. On
|
||||
> WebSocket / HTTPUpgrade / XHTTP, xray applies the HTTP header *last*, so a stale
|
||||
> `trustedXForwardedFor` would override (and defeat) a PROXY-protocol setup. The presets are
|
||||
> mutually exclusive and clear the other field for you; only mix them by hand if you know your
|
||||
> upstream chain needs it.
|
||||
|
||||
## Multi-node
|
||||
|
||||
No extra configuration is needed. The inbound's `streamSettings` (including these sockopt
|
||||
fields) is pushed to child nodes verbatim, so the node's Xray records the real IP, and the
|
||||
parent panel pulls each node's per-client IPs roughly every 10 seconds. The real visitor IP
|
||||
shows up on the parent automatically.
|
||||
|
||||
## Security note
|
||||
|
||||
Both `acceptProxyProtocol` and `trustedXForwardedFor` are **server-side only** — they are
|
||||
stripped from subscription output, so they never reach clients. Only enable
|
||||
`trustedXForwardedFor` when the inbound is genuinely behind a trusted proxy that sets the
|
||||
header; otherwise a client could spoof the header and forge its own source IP.
|
||||
|
||||
## Verifying
|
||||
|
||||
1. Set the preset and save the inbound.
|
||||
2. Inspect the generated Xray config and confirm `streamSettings.sockopt` carries the expected
|
||||
field (`trustedXForwardedFor` or `acceptProxyProtocol`).
|
||||
3. Connect through the intermediary, then open the client's IPs / online view in the panel — it
|
||||
should show the real visitor IP rather than the CDN/relay address.
|
||||
292
frontend/package-lock.json
generated
292
frontend/package-lock.json
generated
@ -13,8 +13,8 @@
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@tanstack/react-query-devtools": "^5.101.0",
|
||||
"antd": "^6.4.3",
|
||||
"axios": "^1.17.0",
|
||||
"antd": "^6.4.4",
|
||||
"axios": "^1.18.0",
|
||||
"codemirror": "^6.0.2",
|
||||
"dayjs": "^1.11.21",
|
||||
"i18next": "^26.3.1",
|
||||
@ -24,7 +24,7 @@
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-router-dom": "^7.16.0",
|
||||
"react-router-dom": "^7.17.0",
|
||||
"recharts": "^3.8.1",
|
||||
"swagger-ui-react": "^5.32.6",
|
||||
"zod": "^4.4.3"
|
||||
@ -33,19 +33,19 @@
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.2.16",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/swagger-ui-react": "^5.18.0",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"eslint": "^10.4.1",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"eslint": "^10.5.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"globals": "^17.6.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.60.1",
|
||||
"typescript-eslint": "^8.61.1",
|
||||
"vite": "8.0.16",
|
||||
"vitest": "^4.1.8"
|
||||
"vitest": "^4.1.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0",
|
||||
@ -628,9 +628,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-color-parser": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.3.tgz",
|
||||
"integrity": "sha512-DOgvIPkikIOixQRlD4YF31VN6fLLUTdrzhfRbis8vm0kMTgIbEPX0Ip/YX9fOeV9iywAS4sUUbTclpan7yYP8Q==",
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.7.tgz",
|
||||
"integrity": "sha512-CmjJFQTFQx/U/xNJhSjCQ0ilpesPmNQ8+eOUeM/+kDOVW33qsIjeOXc27vrQDdWVkf83ZSWwtg7kXSUvKDJ8cQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@ -1247,9 +1247,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rc-component/form": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/form/-/form-1.8.3.tgz",
|
||||
"integrity": "sha512-jNkat3uxZ246ELudKwnjQhnDI8+rSxgLxjztvQU3Mrb0G+LwDyOrPu9RNfekOjqU5GQ5QJepi225x+9LhCizJw==",
|
||||
"version": "1.8.4",
|
||||
"resolved": "https://registry.npmjs.org/@rc-component/form/-/form-1.8.4.tgz",
|
||||
"integrity": "sha512-I2FHNMWoiGQNjC+hQFhAj/rQeScAIBc+AkZqvu4Zyaxe4I3WOVpQte2E5lyZhruswyT8aULYHu1clPaPwE9L2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@rc-component/async-validator": "^6.0.0",
|
||||
@ -3107,17 +3107,17 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz",
|
||||
"integrity": "sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==",
|
||||
"version": "8.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz",
|
||||
"integrity": "sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.61.0",
|
||||
"@typescript-eslint/type-utils": "8.61.0",
|
||||
"@typescript-eslint/utils": "8.61.0",
|
||||
"@typescript-eslint/visitor-keys": "8.61.0",
|
||||
"@typescript-eslint/scope-manager": "8.61.1",
|
||||
"@typescript-eslint/type-utils": "8.61.1",
|
||||
"@typescript-eslint/utils": "8.61.1",
|
||||
"@typescript-eslint/visitor-keys": "8.61.1",
|
||||
"ignore": "^7.0.5",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
@ -3130,7 +3130,7 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.61.0",
|
||||
"@typescript-eslint/parser": "^8.61.1",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
@ -3146,16 +3146,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.0.tgz",
|
||||
"integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==",
|
||||
"version": "8.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.1.tgz",
|
||||
"integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.61.0",
|
||||
"@typescript-eslint/types": "8.61.0",
|
||||
"@typescript-eslint/typescript-estree": "8.61.0",
|
||||
"@typescript-eslint/visitor-keys": "8.61.0",
|
||||
"@typescript-eslint/scope-manager": "8.61.1",
|
||||
"@typescript-eslint/types": "8.61.1",
|
||||
"@typescript-eslint/typescript-estree": "8.61.1",
|
||||
"@typescript-eslint/visitor-keys": "8.61.1",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@ -3171,14 +3171,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.0.tgz",
|
||||
"integrity": "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==",
|
||||
"version": "8.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.1.tgz",
|
||||
"integrity": "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.61.0",
|
||||
"@typescript-eslint/types": "^8.61.0",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.61.1",
|
||||
"@typescript-eslint/types": "^8.61.1",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@ -3193,14 +3193,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.0.tgz",
|
||||
"integrity": "sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==",
|
||||
"version": "8.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.1.tgz",
|
||||
"integrity": "sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.61.0",
|
||||
"@typescript-eslint/visitor-keys": "8.61.0"
|
||||
"@typescript-eslint/types": "8.61.1",
|
||||
"@typescript-eslint/visitor-keys": "8.61.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@ -3211,9 +3211,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.0.tgz",
|
||||
"integrity": "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==",
|
||||
"version": "8.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz",
|
||||
"integrity": "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@ -3228,15 +3228,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.0.tgz",
|
||||
"integrity": "sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==",
|
||||
"version": "8.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.1.tgz",
|
||||
"integrity": "sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.61.0",
|
||||
"@typescript-eslint/typescript-estree": "8.61.0",
|
||||
"@typescript-eslint/utils": "8.61.0",
|
||||
"@typescript-eslint/types": "8.61.1",
|
||||
"@typescript-eslint/typescript-estree": "8.61.1",
|
||||
"@typescript-eslint/utils": "8.61.1",
|
||||
"debug": "^4.4.3",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
@ -3253,9 +3253,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.0.tgz",
|
||||
"integrity": "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==",
|
||||
"version": "8.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.1.tgz",
|
||||
"integrity": "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@ -3267,16 +3267,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.0.tgz",
|
||||
"integrity": "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==",
|
||||
"version": "8.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.1.tgz",
|
||||
"integrity": "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.61.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.61.0",
|
||||
"@typescript-eslint/types": "8.61.0",
|
||||
"@typescript-eslint/visitor-keys": "8.61.0",
|
||||
"@typescript-eslint/project-service": "8.61.1",
|
||||
"@typescript-eslint/tsconfig-utils": "8.61.1",
|
||||
"@typescript-eslint/types": "8.61.1",
|
||||
"@typescript-eslint/visitor-keys": "8.61.1",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
@ -3308,16 +3308,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.0.tgz",
|
||||
"integrity": "sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==",
|
||||
"version": "8.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.1.tgz",
|
||||
"integrity": "sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.61.0",
|
||||
"@typescript-eslint/types": "8.61.0",
|
||||
"@typescript-eslint/typescript-estree": "8.61.0"
|
||||
"@typescript-eslint/scope-manager": "8.61.1",
|
||||
"@typescript-eslint/types": "8.61.1",
|
||||
"@typescript-eslint/typescript-estree": "8.61.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@ -3332,13 +3332,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.0.tgz",
|
||||
"integrity": "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==",
|
||||
"version": "8.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.1.tgz",
|
||||
"integrity": "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.61.0",
|
||||
"@typescript-eslint/types": "8.61.1",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@ -3376,14 +3376,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz",
|
||||
"integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==",
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz",
|
||||
"integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@bcoe/v8-coverage": "^1.0.2",
|
||||
"@vitest/utils": "4.1.8",
|
||||
"@vitest/utils": "4.1.9",
|
||||
"ast-v8-to-istanbul": "^1.0.0",
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
@ -3397,8 +3397,8 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vitest/browser": "4.1.8",
|
||||
"vitest": "4.1.8"
|
||||
"@vitest/browser": "4.1.9",
|
||||
"vitest": "4.1.9"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vitest/browser": {
|
||||
@ -3407,16 +3407,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz",
|
||||
"integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==",
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
|
||||
"integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.8",
|
||||
"@vitest/utils": "4.1.8",
|
||||
"@vitest/spy": "4.1.9",
|
||||
"@vitest/utils": "4.1.9",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@ -3425,13 +3425,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz",
|
||||
"integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==",
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
|
||||
"integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.8",
|
||||
"@vitest/spy": "4.1.9",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
@ -3452,9 +3452,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz",
|
||||
"integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==",
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz",
|
||||
"integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -3465,13 +3465,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz",
|
||||
"integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==",
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz",
|
||||
"integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.8",
|
||||
"@vitest/utils": "4.1.9",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
@ -3479,14 +3479,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz",
|
||||
"integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==",
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz",
|
||||
"integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.8",
|
||||
"@vitest/utils": "4.1.8",
|
||||
"@vitest/pretty-format": "4.1.9",
|
||||
"@vitest/utils": "4.1.9",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
@ -3495,9 +3495,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz",
|
||||
"integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==",
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz",
|
||||
"integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@ -3505,13 +3505,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz",
|
||||
"integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==",
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz",
|
||||
"integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.8",
|
||||
"@vitest/pretty-format": "4.1.9",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@ -3739,9 +3739,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.17.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz",
|
||||
"integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==",
|
||||
"version": "1.18.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz",
|
||||
"integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.16.0",
|
||||
@ -5450,9 +5450,19 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
|
||||
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
@ -7346,22 +7356,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "7.4.2",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.2.tgz",
|
||||
"integrity": "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==",
|
||||
"version": "7.4.3",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.3.tgz",
|
||||
"integrity": "sha512-A3BDQBeeukYPzB4QdQ1DtdlUmp4x2OCH8n5UVhEWbyANxNep8GavottKzd1xYKFJKjUgMyPT7EzOfnBO55s8Sg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tldts-core": "^7.4.2"
|
||||
"tldts-core": "^7.4.3"
|
||||
},
|
||||
"bin": {
|
||||
"tldts": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts-core": {
|
||||
"version": "7.4.2",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.2.tgz",
|
||||
"integrity": "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==",
|
||||
"version": "7.4.3",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.3.tgz",
|
||||
"integrity": "sha512-27ep5H9PzdBrNd5OFM/j3WCU8F3kPwM9D0BOaOf7uYfxMJfyr0K5Tjj69Gri+sZlh2WXd5buIm47NuPF29CDiw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@ -7537,16 +7547,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint": {
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.0.tgz",
|
||||
"integrity": "sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw==",
|
||||
"version": "8.61.1",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.1.tgz",
|
||||
"integrity": "sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "8.61.0",
|
||||
"@typescript-eslint/parser": "8.61.0",
|
||||
"@typescript-eslint/typescript-estree": "8.61.0",
|
||||
"@typescript-eslint/utils": "8.61.0"
|
||||
"@typescript-eslint/eslint-plugin": "8.61.1",
|
||||
"@typescript-eslint/parser": "8.61.1",
|
||||
"@typescript-eslint/typescript-estree": "8.61.1",
|
||||
"@typescript-eslint/utils": "8.61.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@ -7561,9 +7571,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.27.2",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz",
|
||||
"integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==",
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
|
||||
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@ -7737,19 +7747,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz",
|
||||
"integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==",
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz",
|
||||
"integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.8",
|
||||
"@vitest/mocker": "4.1.8",
|
||||
"@vitest/pretty-format": "4.1.8",
|
||||
"@vitest/runner": "4.1.8",
|
||||
"@vitest/snapshot": "4.1.8",
|
||||
"@vitest/spy": "4.1.8",
|
||||
"@vitest/utils": "4.1.8",
|
||||
"@vitest/expect": "4.1.9",
|
||||
"@vitest/mocker": "4.1.9",
|
||||
"@vitest/pretty-format": "4.1.9",
|
||||
"@vitest/runner": "4.1.9",
|
||||
"@vitest/snapshot": "4.1.9",
|
||||
"@vitest/spy": "4.1.9",
|
||||
"@vitest/utils": "4.1.9",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
@ -7777,12 +7787,12 @@
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.8",
|
||||
"@vitest/browser-preview": "4.1.8",
|
||||
"@vitest/browser-webdriverio": "4.1.8",
|
||||
"@vitest/coverage-istanbul": "4.1.8",
|
||||
"@vitest/coverage-v8": "4.1.8",
|
||||
"@vitest/ui": "4.1.8",
|
||||
"@vitest/browser-playwright": "4.1.9",
|
||||
"@vitest/browser-preview": "4.1.9",
|
||||
"@vitest/browser-webdriverio": "4.1.9",
|
||||
"@vitest/coverage-istanbul": "4.1.9",
|
||||
"@vitest/coverage-v8": "4.1.9",
|
||||
"@vitest/ui": "4.1.9",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
|
||||
@ -26,8 +26,8 @@
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@tanstack/react-query-devtools": "^5.101.0",
|
||||
"antd": "^6.4.3",
|
||||
"axios": "^1.17.0",
|
||||
"antd": "^6.4.4",
|
||||
"axios": "^1.18.0",
|
||||
"codemirror": "^6.0.2",
|
||||
"dayjs": "^1.11.21",
|
||||
"i18next": "^26.3.1",
|
||||
@ -37,7 +37,7 @@
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-router-dom": "^7.16.0",
|
||||
"react-router-dom": "^7.17.0",
|
||||
"recharts": "^3.8.1",
|
||||
"swagger-ui-react": "^5.32.6",
|
||||
"zod": "^4.4.3"
|
||||
@ -46,25 +46,28 @@
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.2.16",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/swagger-ui-react": "^5.18.0",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"eslint": "^10.4.1",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"eslint": "^10.5.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"globals": "^17.6.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.60.1",
|
||||
"typescript-eslint": "^8.61.1",
|
||||
"vite": "8.0.16",
|
||||
"vitest": "^4.1.8"
|
||||
"vitest": "^4.1.9"
|
||||
},
|
||||
"overrides": {
|
||||
"react-copy-to-clipboard": "^5.1.1",
|
||||
"react-inspector": "^9.0.0",
|
||||
"react-debounce-input": {
|
||||
"react": "^19.0.0"
|
||||
},
|
||||
"swagger-ui-react": {
|
||||
"js-yaml": "^4.2.0"
|
||||
}
|
||||
},
|
||||
"allowScripts": {
|
||||
|
||||
@ -138,6 +138,46 @@
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"smtpCpu": {
|
||||
"description": "CPU threshold for email notifications",
|
||||
"maximum": 100,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"smtpEnable": {
|
||||
"description": "Email (SMTP) notification settings\nEnable email notifications",
|
||||
"type": "boolean"
|
||||
},
|
||||
"smtpEnabledEvents": {
|
||||
"description": "Comma-separated event types to send via email",
|
||||
"type": "string"
|
||||
},
|
||||
"smtpEncryptionType": {
|
||||
"description": "SMTP encryption: none, starttls, tls",
|
||||
"type": "string"
|
||||
},
|
||||
"smtpHost": {
|
||||
"description": "SMTP server host",
|
||||
"type": "string"
|
||||
},
|
||||
"smtpPassword": {
|
||||
"description": "SMTP password",
|
||||
"type": "string"
|
||||
},
|
||||
"smtpPort": {
|
||||
"description": "SMTP server port",
|
||||
"maximum": 65535,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"smtpTo": {
|
||||
"description": "Comma-separated recipient emails",
|
||||
"type": "string"
|
||||
},
|
||||
"smtpUsername": {
|
||||
"description": "SMTP username",
|
||||
"type": "string"
|
||||
},
|
||||
"subAnnounce": {
|
||||
"description": "Subscription announce",
|
||||
"type": "string"
|
||||
@ -277,10 +317,6 @@
|
||||
"description": "Telegram bot settings\nEnable Telegram bot notifications",
|
||||
"type": "boolean"
|
||||
},
|
||||
"tgBotLoginNotify": {
|
||||
"description": "Send login notifications",
|
||||
"type": "boolean"
|
||||
},
|
||||
"tgBotProxy": {
|
||||
"description": "Proxy URL for Telegram bot",
|
||||
"type": "string"
|
||||
@ -295,6 +331,10 @@
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"tgEnabledEvents": {
|
||||
"description": "Comma-separated event types to send via Telegram",
|
||||
"type": "string"
|
||||
},
|
||||
"tgLang": {
|
||||
"description": "Telegram bot language",
|
||||
"type": "string"
|
||||
@ -387,6 +427,15 @@
|
||||
"remarkModel",
|
||||
"restartXrayOnClientDisable",
|
||||
"sessionMaxAge",
|
||||
"smtpCpu",
|
||||
"smtpEnable",
|
||||
"smtpEnabledEvents",
|
||||
"smtpEncryptionType",
|
||||
"smtpHost",
|
||||
"smtpPassword",
|
||||
"smtpPort",
|
||||
"smtpTo",
|
||||
"smtpUsername",
|
||||
"subAnnounce",
|
||||
"subCertFile",
|
||||
"subClashEnable",
|
||||
@ -421,10 +470,10 @@
|
||||
"tgBotBackup",
|
||||
"tgBotChatId",
|
||||
"tgBotEnable",
|
||||
"tgBotLoginNotify",
|
||||
"tgBotProxy",
|
||||
"tgBotToken",
|
||||
"tgCpu",
|
||||
"tgEnabledEvents",
|
||||
"tgLang",
|
||||
"tgRunTime",
|
||||
"timeLocation",
|
||||
@ -471,6 +520,9 @@
|
||||
"hasNordSecret": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hasSmtpPassword": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hasTgBotToken": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@ -572,6 +624,46 @@
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"smtpCpu": {
|
||||
"description": "CPU threshold for email notifications",
|
||||
"maximum": 100,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"smtpEnable": {
|
||||
"description": "Email (SMTP) notification settings\nEnable email notifications",
|
||||
"type": "boolean"
|
||||
},
|
||||
"smtpEnabledEvents": {
|
||||
"description": "Comma-separated event types to send via email",
|
||||
"type": "string"
|
||||
},
|
||||
"smtpEncryptionType": {
|
||||
"description": "SMTP encryption: none, starttls, tls",
|
||||
"type": "string"
|
||||
},
|
||||
"smtpHost": {
|
||||
"description": "SMTP server host",
|
||||
"type": "string"
|
||||
},
|
||||
"smtpPassword": {
|
||||
"description": "SMTP password",
|
||||
"type": "string"
|
||||
},
|
||||
"smtpPort": {
|
||||
"description": "SMTP server port",
|
||||
"maximum": 65535,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"smtpTo": {
|
||||
"description": "Comma-separated recipient emails",
|
||||
"type": "string"
|
||||
},
|
||||
"smtpUsername": {
|
||||
"description": "SMTP username",
|
||||
"type": "string"
|
||||
},
|
||||
"subAnnounce": {
|
||||
"description": "Subscription announce",
|
||||
"type": "string"
|
||||
@ -711,10 +803,6 @@
|
||||
"description": "Telegram bot settings\nEnable Telegram bot notifications",
|
||||
"type": "boolean"
|
||||
},
|
||||
"tgBotLoginNotify": {
|
||||
"description": "Send login notifications",
|
||||
"type": "boolean"
|
||||
},
|
||||
"tgBotProxy": {
|
||||
"description": "Proxy URL for Telegram bot",
|
||||
"type": "string"
|
||||
@ -729,6 +817,10 @@
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"tgEnabledEvents": {
|
||||
"description": "Comma-separated event types to send via Telegram",
|
||||
"type": "string"
|
||||
},
|
||||
"tgLang": {
|
||||
"description": "Telegram bot language",
|
||||
"type": "string"
|
||||
@ -799,6 +891,7 @@
|
||||
"hasApiToken",
|
||||
"hasLdapPassword",
|
||||
"hasNordSecret",
|
||||
"hasSmtpPassword",
|
||||
"hasTgBotToken",
|
||||
"hasTwoFactorToken",
|
||||
"hasWarpSecret",
|
||||
@ -827,6 +920,15 @@
|
||||
"remarkModel",
|
||||
"restartXrayOnClientDisable",
|
||||
"sessionMaxAge",
|
||||
"smtpCpu",
|
||||
"smtpEnable",
|
||||
"smtpEnabledEvents",
|
||||
"smtpEncryptionType",
|
||||
"smtpHost",
|
||||
"smtpPassword",
|
||||
"smtpPort",
|
||||
"smtpTo",
|
||||
"smtpUsername",
|
||||
"subAnnounce",
|
||||
"subCertFile",
|
||||
"subClashEnable",
|
||||
@ -861,10 +963,10 @@
|
||||
"tgBotBackup",
|
||||
"tgBotChatId",
|
||||
"tgBotEnable",
|
||||
"tgBotLoginNotify",
|
||||
"tgBotProxy",
|
||||
"tgBotToken",
|
||||
"tgCpu",
|
||||
"tgEnabledEvents",
|
||||
"tgLang",
|
||||
"tgRunTime",
|
||||
"timeLocation",
|
||||
@ -7009,6 +7111,75 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/setting/testSmtp": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Settings"
|
||||
],
|
||||
"summary": "Test SMTP connection with stage-by-stage reporting (connect, auth, send). Returns structured result with stage and message.",
|
||||
"operationId": "post_panel_api_setting_testSmtp",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"obj": {}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"success": true,
|
||||
"stage": "send",
|
||||
"msg": "Test email sent successfully"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/setting/testTgBot": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Settings"
|
||||
],
|
||||
"summary": "Test Telegram bot connection by sending a test message to the configured chat.",
|
||||
"operationId": "post_panel_api_setting_testTgBot",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"obj": {}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"success": true,
|
||||
"msg": "Test message sent to Telegram"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/setting/getDefaultJsonConfig": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
||||
147
frontend/src/components/ui/EventBusCheckboxes.tsx
Normal file
147
frontend/src/components/ui/EventBusCheckboxes.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
import { Checkbox, Collapse, InputNumber, Space } from 'antd';
|
||||
import { DownOutlined, RightOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface EventGroup {
|
||||
key: string;
|
||||
labelKey: string;
|
||||
events: { value: string; labelKey: string }[];
|
||||
}
|
||||
|
||||
const EVENT_GROUPS: EventGroup[] = [
|
||||
{
|
||||
key: 'outbound',
|
||||
labelKey: 'pages.settings.eventGroupOutbound',
|
||||
events: [
|
||||
{ value: 'outbound.down', labelKey: 'pages.settings.eventOutboundDown' },
|
||||
{ value: 'outbound.up', labelKey: 'pages.settings.eventOutboundUp' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'xray',
|
||||
labelKey: 'pages.settings.eventGroupXray',
|
||||
events: [
|
||||
{ value: 'xray.crash', labelKey: 'pages.settings.eventXrayCrash' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'node',
|
||||
labelKey: 'pages.settings.eventGroupNode',
|
||||
events: [
|
||||
{ value: 'node.down', labelKey: 'pages.settings.eventNodeDown' },
|
||||
{ value: 'node.up', labelKey: 'pages.settings.eventNodeUp' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'system',
|
||||
labelKey: 'pages.settings.eventGroupSystem',
|
||||
events: [
|
||||
{ value: 'cpu.high', labelKey: 'pages.settings.eventCPUHigh' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'security',
|
||||
labelKey: 'pages.settings.eventGroupSecurity',
|
||||
events: [
|
||||
{ value: 'login.attempt', labelKey: 'pages.settings.eventLoginAttempt' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface EventBusCheckboxesProps {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
/** Maps event value → { key: setting field name, value: current value } for inline inputs */
|
||||
extra?: Record<string, { key: string; value: number }>;
|
||||
/** Callback when extra input changes: (settingKey, newValue) => void */
|
||||
onExtraChange?: (key: string, v: number | null) => void;
|
||||
}
|
||||
|
||||
export function EventBusCheckboxes({ value, onChange, extra, onExtraChange }: EventBusCheckboxesProps) {
|
||||
const { t } = useTranslation();
|
||||
const selected = value ? value.split(',').map((s) => s.trim()).filter(Boolean) : [];
|
||||
|
||||
function toggle(eventType: string) {
|
||||
const next = selected.includes(eventType)
|
||||
? selected.filter((e) => e !== eventType)
|
||||
: [...selected, eventType];
|
||||
onChange(next.join(','));
|
||||
}
|
||||
|
||||
function toggleGroup(group: EventGroup) {
|
||||
const groupValues = group.events.map((e) => e.value);
|
||||
const allSelected = groupValues.every((v) => selected.includes(v));
|
||||
let next: string[];
|
||||
if (allSelected) {
|
||||
next = selected.filter((v) => !groupValues.includes(v));
|
||||
} else {
|
||||
next = [...new Set([...selected, ...groupValues])];
|
||||
}
|
||||
onChange(next.join(','));
|
||||
}
|
||||
|
||||
const items = EVENT_GROUPS.map((group) => {
|
||||
const count = group.events.filter((e) => selected.includes(e.value)).length;
|
||||
const total = group.events.length;
|
||||
const allSelected = count === total;
|
||||
|
||||
return {
|
||||
key: group.key,
|
||||
label: (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontWeight: 500 }}>{t(group.labelKey)}</span>
|
||||
<span style={{ color: '#999', fontSize: 12 }}>
|
||||
{count}/{total}
|
||||
</span>
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
indeterminate={count > 0 && count < total}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={() => toggleGroup(group)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<Checkbox.Group value={selected} style={{ width: '100%' }}>
|
||||
<Space wrap size={[16, 4]}>
|
||||
{group.events.map((et) => {
|
||||
const checked = selected.includes(et.value);
|
||||
const extraConf = extra?.[et.value];
|
||||
return (
|
||||
<span key={et.value} style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<Checkbox value={et.value} onChange={() => toggle(et.value)}>
|
||||
{t(et.labelKey)}
|
||||
</Checkbox>
|
||||
{extraConf && onExtraChange && (
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0}
|
||||
max={100}
|
||||
value={extraConf.value}
|
||||
disabled={!checked}
|
||||
onChange={(v) => onExtraChange(extraConf.key, v)}
|
||||
style={{ width: 60 }}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
</Checkbox.Group>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const defaultActiveKeys = EVENT_GROUPS
|
||||
.filter((g) => g.events.some((e) => selected.includes(e.value)))
|
||||
.map((g) => g.key);
|
||||
|
||||
return (
|
||||
<Collapse
|
||||
items={items}
|
||||
defaultActiveKey={defaultActiveKeys.length > 0 ? defaultActiveKeys : ['outbound']}
|
||||
expandIcon={({ isActive }) => isActive ? <DownOutlined /> : <RightOutlined />}
|
||||
size="small"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
export { default as InputAddon } from './InputAddon';
|
||||
export { default as InfinityIcon } from './InfinityIcon';
|
||||
export { default as SettingListItem } from './SettingListItem';
|
||||
export { EventBusCheckboxes } from './EventBusCheckboxes';
|
||||
|
||||
@ -30,6 +30,15 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"remarkModel": "",
|
||||
"restartXrayOnClientDisable": false,
|
||||
"sessionMaxAge": 1,
|
||||
"smtpCpu": 0,
|
||||
"smtpEnable": false,
|
||||
"smtpEnabledEvents": "",
|
||||
"smtpEncryptionType": "",
|
||||
"smtpHost": "",
|
||||
"smtpPassword": "",
|
||||
"smtpPort": 1,
|
||||
"smtpTo": "",
|
||||
"smtpUsername": "",
|
||||
"subAnnounce": "",
|
||||
"subCertFile": "",
|
||||
"subClashEnable": false,
|
||||
@ -64,10 +73,10 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"tgBotBackup": false,
|
||||
"tgBotChatId": "",
|
||||
"tgBotEnable": false,
|
||||
"tgBotLoginNotify": false,
|
||||
"tgBotProxy": "",
|
||||
"tgBotToken": "",
|
||||
"tgCpu": 0,
|
||||
"tgEnabledEvents": "",
|
||||
"tgLang": "",
|
||||
"tgRunTime": "",
|
||||
"timeLocation": "",
|
||||
@ -91,6 +100,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"hasApiToken": false,
|
||||
"hasLdapPassword": false,
|
||||
"hasNordSecret": false,
|
||||
"hasSmtpPassword": false,
|
||||
"hasTgBotToken": false,
|
||||
"hasTwoFactorToken": false,
|
||||
"hasWarpSecret": false,
|
||||
@ -119,6 +129,15 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"remarkModel": "",
|
||||
"restartXrayOnClientDisable": false,
|
||||
"sessionMaxAge": 1,
|
||||
"smtpCpu": 0,
|
||||
"smtpEnable": false,
|
||||
"smtpEnabledEvents": "",
|
||||
"smtpEncryptionType": "",
|
||||
"smtpHost": "",
|
||||
"smtpPassword": "",
|
||||
"smtpPort": 1,
|
||||
"smtpTo": "",
|
||||
"smtpUsername": "",
|
||||
"subAnnounce": "",
|
||||
"subCertFile": "",
|
||||
"subClashEnable": false,
|
||||
@ -153,10 +172,10 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"tgBotBackup": false,
|
||||
"tgBotChatId": "",
|
||||
"tgBotEnable": false,
|
||||
"tgBotLoginNotify": false,
|
||||
"tgBotProxy": "",
|
||||
"tgBotToken": "",
|
||||
"tgCpu": 0,
|
||||
"tgEnabledEvents": "",
|
||||
"tgLang": "",
|
||||
"tgRunTime": "",
|
||||
"timeLocation": "",
|
||||
|
||||
@ -112,6 +112,46 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"smtpCpu": {
|
||||
"description": "CPU threshold for email notifications",
|
||||
"maximum": 100,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"smtpEnable": {
|
||||
"description": "Email (SMTP) notification settings\nEnable email notifications",
|
||||
"type": "boolean"
|
||||
},
|
||||
"smtpEnabledEvents": {
|
||||
"description": "Comma-separated event types to send via email",
|
||||
"type": "string"
|
||||
},
|
||||
"smtpEncryptionType": {
|
||||
"description": "SMTP encryption: none, starttls, tls",
|
||||
"type": "string"
|
||||
},
|
||||
"smtpHost": {
|
||||
"description": "SMTP server host",
|
||||
"type": "string"
|
||||
},
|
||||
"smtpPassword": {
|
||||
"description": "SMTP password",
|
||||
"type": "string"
|
||||
},
|
||||
"smtpPort": {
|
||||
"description": "SMTP server port",
|
||||
"maximum": 65535,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"smtpTo": {
|
||||
"description": "Comma-separated recipient emails",
|
||||
"type": "string"
|
||||
},
|
||||
"smtpUsername": {
|
||||
"description": "SMTP username",
|
||||
"type": "string"
|
||||
},
|
||||
"subAnnounce": {
|
||||
"description": "Subscription announce",
|
||||
"type": "string"
|
||||
@ -251,10 +291,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"description": "Telegram bot settings\nEnable Telegram bot notifications",
|
||||
"type": "boolean"
|
||||
},
|
||||
"tgBotLoginNotify": {
|
||||
"description": "Send login notifications",
|
||||
"type": "boolean"
|
||||
},
|
||||
"tgBotProxy": {
|
||||
"description": "Proxy URL for Telegram bot",
|
||||
"type": "string"
|
||||
@ -269,6 +305,10 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"tgEnabledEvents": {
|
||||
"description": "Comma-separated event types to send via Telegram",
|
||||
"type": "string"
|
||||
},
|
||||
"tgLang": {
|
||||
"description": "Telegram bot language",
|
||||
"type": "string"
|
||||
@ -361,6 +401,15 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"remarkModel",
|
||||
"restartXrayOnClientDisable",
|
||||
"sessionMaxAge",
|
||||
"smtpCpu",
|
||||
"smtpEnable",
|
||||
"smtpEnabledEvents",
|
||||
"smtpEncryptionType",
|
||||
"smtpHost",
|
||||
"smtpPassword",
|
||||
"smtpPort",
|
||||
"smtpTo",
|
||||
"smtpUsername",
|
||||
"subAnnounce",
|
||||
"subCertFile",
|
||||
"subClashEnable",
|
||||
@ -395,10 +444,10 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"tgBotBackup",
|
||||
"tgBotChatId",
|
||||
"tgBotEnable",
|
||||
"tgBotLoginNotify",
|
||||
"tgBotProxy",
|
||||
"tgBotToken",
|
||||
"tgCpu",
|
||||
"tgEnabledEvents",
|
||||
"tgLang",
|
||||
"tgRunTime",
|
||||
"timeLocation",
|
||||
@ -445,6 +494,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"hasNordSecret": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hasSmtpPassword": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hasTgBotToken": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@ -546,6 +598,46 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"smtpCpu": {
|
||||
"description": "CPU threshold for email notifications",
|
||||
"maximum": 100,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"smtpEnable": {
|
||||
"description": "Email (SMTP) notification settings\nEnable email notifications",
|
||||
"type": "boolean"
|
||||
},
|
||||
"smtpEnabledEvents": {
|
||||
"description": "Comma-separated event types to send via email",
|
||||
"type": "string"
|
||||
},
|
||||
"smtpEncryptionType": {
|
||||
"description": "SMTP encryption: none, starttls, tls",
|
||||
"type": "string"
|
||||
},
|
||||
"smtpHost": {
|
||||
"description": "SMTP server host",
|
||||
"type": "string"
|
||||
},
|
||||
"smtpPassword": {
|
||||
"description": "SMTP password",
|
||||
"type": "string"
|
||||
},
|
||||
"smtpPort": {
|
||||
"description": "SMTP server port",
|
||||
"maximum": 65535,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"smtpTo": {
|
||||
"description": "Comma-separated recipient emails",
|
||||
"type": "string"
|
||||
},
|
||||
"smtpUsername": {
|
||||
"description": "SMTP username",
|
||||
"type": "string"
|
||||
},
|
||||
"subAnnounce": {
|
||||
"description": "Subscription announce",
|
||||
"type": "string"
|
||||
@ -685,10 +777,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"description": "Telegram bot settings\nEnable Telegram bot notifications",
|
||||
"type": "boolean"
|
||||
},
|
||||
"tgBotLoginNotify": {
|
||||
"description": "Send login notifications",
|
||||
"type": "boolean"
|
||||
},
|
||||
"tgBotProxy": {
|
||||
"description": "Proxy URL for Telegram bot",
|
||||
"type": "string"
|
||||
@ -703,6 +791,10 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"tgEnabledEvents": {
|
||||
"description": "Comma-separated event types to send via Telegram",
|
||||
"type": "string"
|
||||
},
|
||||
"tgLang": {
|
||||
"description": "Telegram bot language",
|
||||
"type": "string"
|
||||
@ -773,6 +865,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"hasApiToken",
|
||||
"hasLdapPassword",
|
||||
"hasNordSecret",
|
||||
"hasSmtpPassword",
|
||||
"hasTgBotToken",
|
||||
"hasTwoFactorToken",
|
||||
"hasWarpSecret",
|
||||
@ -801,6 +894,15 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"remarkModel",
|
||||
"restartXrayOnClientDisable",
|
||||
"sessionMaxAge",
|
||||
"smtpCpu",
|
||||
"smtpEnable",
|
||||
"smtpEnabledEvents",
|
||||
"smtpEncryptionType",
|
||||
"smtpHost",
|
||||
"smtpPassword",
|
||||
"smtpPort",
|
||||
"smtpTo",
|
||||
"smtpUsername",
|
||||
"subAnnounce",
|
||||
"subCertFile",
|
||||
"subClashEnable",
|
||||
@ -835,10 +937,10 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"tgBotBackup",
|
||||
"tgBotChatId",
|
||||
"tgBotEnable",
|
||||
"tgBotLoginNotify",
|
||||
"tgBotProxy",
|
||||
"tgBotToken",
|
||||
"tgCpu",
|
||||
"tgEnabledEvents",
|
||||
"tgLang",
|
||||
"tgRunTime",
|
||||
"timeLocation",
|
||||
|
||||
@ -3,6 +3,7 @@ export type OnlineAPISupport = number;
|
||||
export type ProcessState = string;
|
||||
export type Protocol = string;
|
||||
export type SubLinkProvider = unknown;
|
||||
export type staticEgressResolver = string;
|
||||
export type transportBits = number;
|
||||
|
||||
export interface AllSetting {
|
||||
@ -35,6 +36,15 @@ export interface AllSetting {
|
||||
remarkModel: string;
|
||||
restartXrayOnClientDisable: boolean;
|
||||
sessionMaxAge: number;
|
||||
smtpCpu: number;
|
||||
smtpEnable: boolean;
|
||||
smtpEnabledEvents: string;
|
||||
smtpEncryptionType: string;
|
||||
smtpHost: string;
|
||||
smtpPassword: string;
|
||||
smtpPort: number;
|
||||
smtpTo: string;
|
||||
smtpUsername: string;
|
||||
subAnnounce: string;
|
||||
subCertFile: string;
|
||||
subClashEnable: boolean;
|
||||
@ -69,10 +79,10 @@ export interface AllSetting {
|
||||
tgBotBackup: boolean;
|
||||
tgBotChatId: string;
|
||||
tgBotEnable: boolean;
|
||||
tgBotLoginNotify: boolean;
|
||||
tgBotProxy: string;
|
||||
tgBotToken: string;
|
||||
tgCpu: number;
|
||||
tgEnabledEvents: string;
|
||||
tgLang: string;
|
||||
tgRunTime: string;
|
||||
timeLocation: string;
|
||||
@ -97,6 +107,7 @@ export interface AllSettingView {
|
||||
hasApiToken: boolean;
|
||||
hasLdapPassword: boolean;
|
||||
hasNordSecret: boolean;
|
||||
hasSmtpPassword: boolean;
|
||||
hasTgBotToken: boolean;
|
||||
hasTwoFactorToken: boolean;
|
||||
hasWarpSecret: boolean;
|
||||
@ -125,6 +136,15 @@ export interface AllSettingView {
|
||||
remarkModel: string;
|
||||
restartXrayOnClientDisable: boolean;
|
||||
sessionMaxAge: number;
|
||||
smtpCpu: number;
|
||||
smtpEnable: boolean;
|
||||
smtpEnabledEvents: string;
|
||||
smtpEncryptionType: string;
|
||||
smtpHost: string;
|
||||
smtpPassword: string;
|
||||
smtpPort: number;
|
||||
smtpTo: string;
|
||||
smtpUsername: string;
|
||||
subAnnounce: string;
|
||||
subCertFile: string;
|
||||
subClashEnable: boolean;
|
||||
@ -159,10 +179,10 @@ export interface AllSettingView {
|
||||
tgBotBackup: boolean;
|
||||
tgBotChatId: string;
|
||||
tgBotEnable: boolean;
|
||||
tgBotLoginNotify: boolean;
|
||||
tgBotProxy: string;
|
||||
tgBotToken: string;
|
||||
tgCpu: number;
|
||||
tgEnabledEvents: string;
|
||||
tgLang: string;
|
||||
tgRunTime: string;
|
||||
timeLocation: string;
|
||||
|
||||
@ -12,6 +12,9 @@ export type Protocol = z.infer<typeof ProtocolSchema>;
|
||||
export const SubLinkProviderSchema = z.unknown();
|
||||
export type SubLinkProvider = z.infer<typeof SubLinkProviderSchema>;
|
||||
|
||||
export const staticEgressResolverSchema = z.string();
|
||||
export type staticEgressResolver = z.infer<typeof staticEgressResolverSchema>;
|
||||
|
||||
export const transportBitsSchema = z.number().int();
|
||||
export type transportBits = z.infer<typeof transportBitsSchema>;
|
||||
|
||||
@ -45,6 +48,15 @@ export const AllSettingSchema = z.object({
|
||||
remarkModel: z.string(),
|
||||
restartXrayOnClientDisable: z.boolean(),
|
||||
sessionMaxAge: z.number().int().min(1).max(525600),
|
||||
smtpCpu: z.number().int().min(0).max(100),
|
||||
smtpEnable: z.boolean(),
|
||||
smtpEnabledEvents: z.string(),
|
||||
smtpEncryptionType: z.string(),
|
||||
smtpHost: z.string(),
|
||||
smtpPassword: z.string(),
|
||||
smtpPort: z.number().int().min(1).max(65535),
|
||||
smtpTo: z.string(),
|
||||
smtpUsername: z.string(),
|
||||
subAnnounce: z.string(),
|
||||
subCertFile: z.string(),
|
||||
subClashEnable: z.boolean(),
|
||||
@ -79,10 +91,10 @@ export const AllSettingSchema = z.object({
|
||||
tgBotBackup: z.boolean(),
|
||||
tgBotChatId: z.string(),
|
||||
tgBotEnable: z.boolean(),
|
||||
tgBotLoginNotify: z.boolean(),
|
||||
tgBotProxy: z.string(),
|
||||
tgBotToken: z.string(),
|
||||
tgCpu: z.number().int().min(0).max(100),
|
||||
tgEnabledEvents: z.string(),
|
||||
tgLang: z.string(),
|
||||
tgRunTime: z.string(),
|
||||
timeLocation: z.string(),
|
||||
@ -108,6 +120,7 @@ export const AllSettingViewSchema = z.object({
|
||||
hasApiToken: z.boolean(),
|
||||
hasLdapPassword: z.boolean(),
|
||||
hasNordSecret: z.boolean(),
|
||||
hasSmtpPassword: z.boolean(),
|
||||
hasTgBotToken: z.boolean(),
|
||||
hasTwoFactorToken: z.boolean(),
|
||||
hasWarpSecret: z.boolean(),
|
||||
@ -136,6 +149,15 @@ export const AllSettingViewSchema = z.object({
|
||||
remarkModel: z.string(),
|
||||
restartXrayOnClientDisable: z.boolean(),
|
||||
sessionMaxAge: z.number().int().min(1).max(525600),
|
||||
smtpCpu: z.number().int().min(0).max(100),
|
||||
smtpEnable: z.boolean(),
|
||||
smtpEnabledEvents: z.string(),
|
||||
smtpEncryptionType: z.string(),
|
||||
smtpHost: z.string(),
|
||||
smtpPassword: z.string(),
|
||||
smtpPort: z.number().int().min(1).max(65535),
|
||||
smtpTo: z.string(),
|
||||
smtpUsername: z.string(),
|
||||
subAnnounce: z.string(),
|
||||
subCertFile: z.string(),
|
||||
subClashEnable: z.boolean(),
|
||||
@ -170,10 +192,10 @@ export const AllSettingViewSchema = z.object({
|
||||
tgBotBackup: z.boolean(),
|
||||
tgBotChatId: z.string(),
|
||||
tgBotEnable: z.boolean(),
|
||||
tgBotLoginNotify: z.boolean(),
|
||||
tgBotProxy: z.string(),
|
||||
tgBotToken: z.string(),
|
||||
tgCpu: z.number().int().min(0).max(100),
|
||||
tgEnabledEvents: z.string(),
|
||||
tgLang: z.string(),
|
||||
tgRunTime: z.string(),
|
||||
timeLocation: z.string(),
|
||||
|
||||
@ -16,6 +16,7 @@ import {
|
||||
HeartOutlined,
|
||||
ImportOutlined,
|
||||
LogoutOutlined,
|
||||
MailOutlined,
|
||||
MenuOutlined,
|
||||
MessageOutlined,
|
||||
MoonFilled,
|
||||
@ -153,6 +154,7 @@ export default function AppSidebar() {
|
||||
{ key: '/settings#general', icon: <SettingOutlined />, label: t('pages.settings.panelSettings') },
|
||||
{ key: '/settings#security', icon: <SafetyOutlined />, label: t('pages.settings.securitySettings') },
|
||||
{ key: '/settings#telegram', icon: <MessageOutlined />, label: t('pages.settings.TGBotSettings') },
|
||||
{ key: '/settings#email', icon: <MailOutlined />, label: t('pages.settings.emailSettings') },
|
||||
{ key: '/settings#subscription', icon: <CloudServerOutlined />, label: t('pages.settings.subSettings') },
|
||||
];
|
||||
if (showSubFormats) {
|
||||
|
||||
33
frontend/src/lib/clients/ip-log.ts
Normal file
33
frontend/src/lib/clients/ip-log.ts
Normal file
@ -0,0 +1,33 @@
|
||||
// Shape of one entry in a client's IP log, as returned by
|
||||
// POST /panel/api/clients/ips/:email. `node` is the name of the node the IP is
|
||||
// connecting through, or '' when it is on this local panel (or unattributed).
|
||||
export type ClientIpInfo = {
|
||||
ip: string;
|
||||
time: string;
|
||||
node: string;
|
||||
};
|
||||
|
||||
// normalizeClientIps accepts the API payload and returns typed entries. It also
|
||||
// tolerates the legacy shape (a plain array of "ip (time)" strings) so the UI
|
||||
// keeps working against older panels.
|
||||
export function normalizeClientIps(obj: unknown): ClientIpInfo[] {
|
||||
if (!Array.isArray(obj)) return [];
|
||||
const out: ClientIpInfo[] = [];
|
||||
for (const x of obj) {
|
||||
if (typeof x === 'string') {
|
||||
if (x.length > 0) out.push({ ip: x, time: '', node: '' });
|
||||
continue;
|
||||
}
|
||||
if (x && typeof x === 'object') {
|
||||
const o = x as Record<string, unknown>;
|
||||
const ip = typeof o.ip === 'string' ? o.ip : '';
|
||||
if (!ip) continue;
|
||||
out.push({
|
||||
ip,
|
||||
time: typeof o.time === 'string' ? o.time : '',
|
||||
node: typeof o.node === 'string' ? o.node : '',
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@ -17,12 +17,10 @@ export class AllSetting {
|
||||
datepicker: 'gregorian' | 'jalalian' = 'gregorian';
|
||||
tgBotEnable = false;
|
||||
tgBotToken = '';
|
||||
tgBotProxy = '';
|
||||
tgBotAPIServer = '';
|
||||
tgBotChatId = '';
|
||||
tgRunTime = '@daily';
|
||||
tgBotBackup = false;
|
||||
tgBotLoginNotify = true;
|
||||
tgCpu = 80;
|
||||
tgLang = 'en-US';
|
||||
twoFactorEnable = false;
|
||||
@ -84,12 +82,23 @@ export class AllSetting {
|
||||
ldapDefaultTotalGB = 0;
|
||||
ldapDefaultExpiryDays = 0;
|
||||
ldapDefaultLimitIP = 0;
|
||||
tgEnabledEvents = '';
|
||||
smtpEnable = false;
|
||||
smtpHost = '';
|
||||
smtpPort = 587;
|
||||
smtpUsername = '';
|
||||
smtpPassword = '';
|
||||
smtpTo = '';
|
||||
smtpEncryptionType = 'starttls';
|
||||
smtpEnabledEvents = '';
|
||||
smtpCpu = 80;
|
||||
hasTgBotToken = false;
|
||||
hasTwoFactorToken = false;
|
||||
hasLdapPassword = false;
|
||||
hasApiToken = false;
|
||||
hasWarpSecret = false;
|
||||
hasNordSecret = false;
|
||||
hasSmtpPassword = false;
|
||||
|
||||
constructor(data?: unknown) {
|
||||
if (data != null) {
|
||||
|
||||
@ -942,6 +942,18 @@ export const sections: readonly Section[] = [
|
||||
path: '/panel/api/setting/restartPanel',
|
||||
summary: 'Restart the entire 3x-ui process after a 3-second grace period. The connection drops immediately; the panel comes back online ~5-10 seconds later.',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/setting/testSmtp',
|
||||
summary: 'Test SMTP connection with stage-by-stage reporting (connect, auth, send). Returns structured result with stage and message.',
|
||||
response: '{\n "success": true,\n "stage": "send",\n "msg": "Test email sent successfully"\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/setting/testTgBot',
|
||||
summary: 'Test Telegram bot connection by sending a test message to the configured chat.',
|
||||
response: '{\n "success": true,\n "msg": "Test message sent to Telegram"\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/setting/getDefaultJsonConfig',
|
||||
|
||||
@ -24,6 +24,7 @@ import dayjs from 'dayjs';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { HttpUtil, RandomUtil } from '@/utils';
|
||||
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||
import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
|
||||
import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
|
||||
import { TLS_FLOW_CONTROL } from '@/schemas/primitives';
|
||||
import type { ClientRecord, InboundOption, ExternalLink, ExternalLinkInput } from '@/hooks/useClients';
|
||||
@ -177,7 +178,7 @@ export default function ClientFormModal({
|
||||
const [form, setForm] = useState<FormState>(emptyForm);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const [clientIps, setClientIps] = useState<string[]>([]);
|
||||
const [clientIps, setClientIps] = useState<ClientIpInfo[]>([]);
|
||||
const [ipsLoading, setIpsLoading] = useState(false);
|
||||
const [ipsClearing, setIpsClearing] = useState(false);
|
||||
const [ipsModalOpen, setIpsModalOpen] = useState(false);
|
||||
@ -355,8 +356,7 @@ export default function ClientFormModal({
|
||||
try {
|
||||
const msg = await HttpUtil.post(`/panel/api/clients/ips/${encodeURIComponent(client.email)}`) as ApiMsg<unknown[]>;
|
||||
if (!msg?.success) { setClientIps([]); return; }
|
||||
const arr = Array.isArray(msg.obj) ? msg.obj : [];
|
||||
setClientIps(arr.filter((x): x is string => typeof x === 'string' && x.length > 0));
|
||||
setClientIps(normalizeClientIps(msg.obj));
|
||||
} finally {
|
||||
setIpsLoading(false);
|
||||
}
|
||||
@ -678,71 +678,55 @@ export default function ClientFormModal({
|
||||
label: t('pages.clients.tabCredentials'),
|
||||
children: (
|
||||
<>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item label={t('pages.clients.uuid')}>
|
||||
<Space.Compact style={{ display: 'flex' }}>
|
||||
<Input value={form.uuid} style={{ flex: 1 }} onChange={(e) => update('uuid', e.target.value)} />
|
||||
<Button icon={<ReloadOutlined />} onClick={() => update('uuid', RandomUtil.randomUUID())} />
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item label={t('pages.clients.password')}>
|
||||
<Space.Compact style={{ display: 'flex' }}>
|
||||
<Input value={form.password} style={{ flex: 1 }} onChange={(e) => update('password', e.target.value)} />
|
||||
<Button icon={<ReloadOutlined />} onClick={regeneratePassword} />
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item label={t('pages.clients.uuid')}>
|
||||
<Space.Compact style={{ display: 'flex' }}>
|
||||
<Input value={form.uuid} style={{ flex: 1 }} onChange={(e) => update('uuid', e.target.value)} />
|
||||
<Button icon={<ReloadOutlined />} onClick={() => update('uuid', RandomUtil.randomUUID())} />
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item label={t('pages.clients.subId')}>
|
||||
<Space.Compact style={{ display: 'flex' }}>
|
||||
<Input value={form.subId} style={{ flex: 1 }} onChange={(e) => update('subId', e.target.value)} />
|
||||
<Button icon={<ReloadOutlined />} onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))} />
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item label={t('pages.clients.hysteriaAuth')}>
|
||||
<Space.Compact style={{ display: 'flex' }}>
|
||||
<Input value={form.auth} style={{ flex: 1 }} onChange={(e) => update('auth', e.target.value)} />
|
||||
<Button icon={<ReloadOutlined />} onClick={() => update('auth', RandomUtil.randomLowerAndNum(16))} />
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item label={t('pages.clients.password')}>
|
||||
<Space.Compact style={{ display: 'flex' }}>
|
||||
<Input value={form.password} style={{ flex: 1 }} onChange={(e) => update('password', e.target.value)} />
|
||||
<Button icon={<ReloadOutlined />} onClick={regeneratePassword} />
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
|
||||
<Row gutter={16}>
|
||||
{showFlow && (
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item label={t('pages.clients.flow')}>
|
||||
<Select
|
||||
value={form.flow}
|
||||
onChange={(v) => update('flow', v)}
|
||||
options={[
|
||||
{ value: '', label: t('none') },
|
||||
...FLOW_OPTIONS.map((k) => ({ value: k, label: k })),
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
)}
|
||||
{showSecurity && (
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item label={t('pages.clients.vmessSecurity')}>
|
||||
<Select
|
||||
value={form.security}
|
||||
onChange={(v) => update('security', v)}
|
||||
options={VMESS_SECURITY_OPTIONS.map((k) => ({ value: k, label: k }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
<Form.Item label={t('pages.clients.subId')}>
|
||||
<Space.Compact style={{ display: 'flex' }}>
|
||||
<Input value={form.subId} style={{ flex: 1 }} onChange={(e) => update('subId', e.target.value)} />
|
||||
<Button icon={<ReloadOutlined />} onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))} />
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('pages.clients.hysteriaAuth')}>
|
||||
<Space.Compact style={{ display: 'flex' }}>
|
||||
<Input value={form.auth} style={{ flex: 1 }} onChange={(e) => update('auth', e.target.value)} />
|
||||
<Button icon={<ReloadOutlined />} onClick={() => update('auth', RandomUtil.randomLowerAndNum(16))} />
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
|
||||
{showFlow && (
|
||||
<Form.Item label={t('pages.clients.flow')}>
|
||||
<Select
|
||||
value={form.flow}
|
||||
onChange={(v) => update('flow', v)}
|
||||
options={[
|
||||
{ value: '', label: t('none') },
|
||||
...FLOW_OPTIONS.map((k) => ({ value: k, label: k })),
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
{showSecurity && (
|
||||
<Form.Item label={t('pages.clients.vmessSecurity')}>
|
||||
<Select
|
||||
value={form.security}
|
||||
onChange={(v) => update('security', v)}
|
||||
options={VMESS_SECURITY_OPTIONS.map((k) => ({ value: k, label: k }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
@ -822,7 +806,7 @@ export default function ClientFormModal({
|
||||
>
|
||||
{clientIps.length > 0 ? (
|
||||
<div style={{ maxHeight: 360, overflowY: 'auto' }}>
|
||||
{clientIps.map((ip, idx) => (
|
||||
{clientIps.map((entry, idx) => (
|
||||
<Tag
|
||||
key={idx}
|
||||
color="blue"
|
||||
@ -835,7 +819,10 @@ export default function ClientFormModal({
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||
}}
|
||||
>
|
||||
{ip}
|
||||
{entry.ip}{entry.time ? ` (${entry.time})` : ''}
|
||||
{entry.node ? (
|
||||
<span style={{ marginInlineStart: 6, opacity: 0.85, fontWeight: 600 }}>@ {entry.node}</span>
|
||||
) : null}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -5,6 +5,7 @@ import { CopyOutlined, EyeOutlined, QrcodeOutlined, ReloadOutlined } from '@ant-
|
||||
|
||||
import { ClipboardManager, HttpUtil, IntlUtil, SizeFormatter } from '@/utils';
|
||||
import { formatInboundLabel } from '@/lib/inbounds/label';
|
||||
import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
|
||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
|
||||
import { isPostQuantumLink } from '@/lib/xray/inbound-link';
|
||||
@ -80,7 +81,7 @@ export default function ClientInfoModal({
|
||||
const dateLabel = (ts?: number) => (!ts || ts <= 0 ? '-' : IntlUtil.formatDate(ts, datepicker));
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const [links, setLinks] = useState<string[]>([]);
|
||||
const [clientIps, setClientIps] = useState<string[]>([]);
|
||||
const [clientIps, setClientIps] = useState<ClientIpInfo[]>([]);
|
||||
const [ipsLoading, setIpsLoading] = useState(false);
|
||||
const [ipsClearing, setIpsClearing] = useState(false);
|
||||
const [ipsModalOpen, setIpsModalOpen] = useState(false);
|
||||
@ -144,8 +145,7 @@ export default function ClientInfoModal({
|
||||
try {
|
||||
const msg = await HttpUtil.post(`/panel/api/clients/ips/${encodeURIComponent(client.email)}`) as ApiMsg<unknown[]>;
|
||||
if (!msg?.success) { setClientIps([]); return; }
|
||||
const arr = Array.isArray(msg.obj) ? msg.obj : [];
|
||||
setClientIps(arr.filter((x): x is string => typeof x === 'string' && x.length > 0));
|
||||
setClientIps(normalizeClientIps(msg.obj));
|
||||
} finally {
|
||||
setIpsLoading(false);
|
||||
}
|
||||
@ -503,7 +503,7 @@ export default function ClientInfoModal({
|
||||
>
|
||||
{clientIps.length > 0 ? (
|
||||
<div style={{ maxHeight: 360, overflowY: 'auto' }}>
|
||||
{clientIps.map((ip, idx) => (
|
||||
{clientIps.map((entry, idx) => (
|
||||
<Tag
|
||||
key={idx}
|
||||
color="blue"
|
||||
@ -516,7 +516,10 @@ export default function ClientInfoModal({
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||
}}
|
||||
>
|
||||
{ip}
|
||||
{entry.ip}{entry.time ? ` (${entry.time})` : ''}
|
||||
{entry.node ? (
|
||||
<span style={{ marginInlineStart: 6, opacity: 0.85, fontWeight: 600 }}>@ {entry.node}</span>
|
||||
) : null}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { QuestionCircleOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
Alert,
|
||||
@ -83,6 +84,16 @@ import type { DBInbound } from '@/models/dbinbound';
|
||||
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||
|
||||
|
||||
// Render a field label with a hover tooltip icon instead of an `extra` help line below.
|
||||
const labelWithHint = (label: string, hint: string) => (
|
||||
<span>
|
||||
{label}
|
||||
<Tooltip title={hint}>
|
||||
<QuestionCircleOutlined style={{ marginInlineStart: 4, color: 'rgba(128,128,128,0.65)' }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
);
|
||||
|
||||
const PROTOCOL_OPTIONS = Object.values(Protocols).map((p) => ({ value: p, label: p }));
|
||||
const TRAFFIC_RESETS = ['never', 'hourly', 'daily', 'weekly', 'monthly'] as const;
|
||||
const SHARE_ADDR_STRATEGIES = ['node', 'listen', 'custom'] as const;
|
||||
@ -538,16 +549,14 @@ export default function InboundFormModal({
|
||||
|
||||
<Form.Item
|
||||
name="listen"
|
||||
label={t('pages.inbounds.address')}
|
||||
extra={t('pages.inbounds.form.listenHelp')}
|
||||
label={labelWithHint(t('pages.inbounds.address'), t('pages.inbounds.form.listenHelp'))}
|
||||
>
|
||||
<Input placeholder={t('pages.inbounds.monitorDesc')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="shareAddrStrategy"
|
||||
label={t('pages.inbounds.form.shareAddrStrategy')}
|
||||
extra={t('pages.inbounds.form.shareAddrStrategyHelp')}
|
||||
label={labelWithHint(t('pages.inbounds.form.shareAddrStrategy'), t('pages.inbounds.form.shareAddrStrategyHelp'))}
|
||||
>
|
||||
<Select
|
||||
options={SHARE_ADDR_STRATEGIES
|
||||
@ -562,8 +571,7 @@ export default function InboundFormModal({
|
||||
{shareAddrStrategy === 'custom' && (
|
||||
<Form.Item
|
||||
name="shareAddr"
|
||||
label={t('pages.inbounds.form.shareAddr')}
|
||||
extra={t('pages.inbounds.form.shareAddrHelp')}
|
||||
label={labelWithHint(t('pages.inbounds.form.shareAddr'), t('pages.inbounds.form.shareAddrHelp'))}
|
||||
rules={[{
|
||||
validator: (_, value) => (
|
||||
isValidShareAddrInput(String(value ?? ''))
|
||||
@ -578,8 +586,7 @@ export default function InboundFormModal({
|
||||
|
||||
<Form.Item
|
||||
name="subSortIndex"
|
||||
label={t('pages.inbounds.form.subSortIndex')}
|
||||
extra={t('pages.inbounds.form.subSortIndexHelp')}
|
||||
label={labelWithHint(t('pages.inbounds.form.subSortIndex'), t('pages.inbounds.form.subSortIndexHelp'))}
|
||||
>
|
||||
<InputNumber min={1} />
|
||||
</Form.Item>
|
||||
@ -811,7 +818,7 @@ export default function InboundFormModal({
|
||||
<ExternalProxyForm toggleExternalProxy={toggleExternalProxy} />
|
||||
)}
|
||||
|
||||
<SockoptForm toggleSockopt={toggleSockopt} />
|
||||
<SockoptForm toggleSockopt={toggleSockopt} network={network as string} />
|
||||
|
||||
{/* Transport masks don't apply to tunnel (a transparent forwarder), so
|
||||
its stream tab is just sockopt + TProxy. */}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
|
||||
import { Alert, Button, Form, Input, InputNumber, Segmented, Select, Space, Switch } from 'antd';
|
||||
|
||||
import {
|
||||
Address_Port_Strategy,
|
||||
@ -8,12 +8,68 @@ import {
|
||||
} from '@/schemas/primitives';
|
||||
import { HappyEyeballsSchema } from '@/schemas/protocols/stream/sockopt';
|
||||
|
||||
// Transport key that carries its own acceptProxyProtocol field (mirrored
|
||||
// alongside the sockopt-level one so the PROXY preset never silently no-ops).
|
||||
const TRANSPORT_PROXY_FIELD: Record<string, string> = {
|
||||
tcp: 'tcpSettings',
|
||||
ws: 'wsSettings',
|
||||
httpupgrade: 'httpupgradeSettings',
|
||||
};
|
||||
// Transports on which xray-core honors sockopt.trustedXForwardedFor.
|
||||
const TRUSTED_HEADER_NETWORKS = ['ws', 'httpupgrade', 'xhttp'];
|
||||
|
||||
type RealClientIpPreset = 'off' | 'cloudflare' | 'proxy';
|
||||
|
||||
export default function SockoptForm({
|
||||
toggleSockopt,
|
||||
network,
|
||||
}: {
|
||||
toggleSockopt: (on: boolean) => void;
|
||||
network: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Presets write the same sockopt fields the user could set by hand below,
|
||||
// picking the mechanism xray-core actually honors for the chosen transport:
|
||||
// CF-Connecting-IP via trustedXForwardedFor (ws/httpupgrade/xhttp) or the
|
||||
// PROXY-protocol header via acceptProxyProtocol (every transport but mKCP).
|
||||
const applyRealClientIpPreset = (
|
||||
preset: RealClientIpPreset,
|
||||
getFieldValue: (name: (string | number)[]) => unknown,
|
||||
setFieldValue: (name: (string | number)[], value: unknown) => void,
|
||||
) => {
|
||||
const sockopt = getFieldValue(['streamSettings', 'sockopt']);
|
||||
const sockoptOn =
|
||||
!!sockopt && typeof sockopt === 'object' && Object.keys(sockopt as object).length > 0;
|
||||
if (preset !== 'off' && !sockoptOn) {
|
||||
toggleSockopt(true);
|
||||
}
|
||||
const transportField = TRANSPORT_PROXY_FIELD[network];
|
||||
|
||||
if (preset === 'off') {
|
||||
setFieldValue(['streamSettings', 'sockopt', 'trustedXForwardedFor'], []);
|
||||
setFieldValue(['streamSettings', 'sockopt', 'acceptProxyProtocol'], false);
|
||||
if (transportField) setFieldValue(['streamSettings', transportField, 'acceptProxyProtocol'], false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (preset === 'cloudflare') {
|
||||
const current = getFieldValue(['streamSettings', 'sockopt', 'trustedXForwardedFor']);
|
||||
const list = Array.isArray(current) ? [...(current as string[])] : [];
|
||||
if (!list.includes('CF-Connecting-IP')) list.push('CF-Connecting-IP');
|
||||
setFieldValue(['streamSettings', 'sockopt', 'trustedXForwardedFor'], list);
|
||||
setFieldValue(['streamSettings', 'sockopt', 'acceptProxyProtocol'], false);
|
||||
if (transportField) setFieldValue(['streamSettings', transportField, 'acceptProxyProtocol'], false);
|
||||
return;
|
||||
}
|
||||
|
||||
// proxy — clear trustedXForwardedFor so a lingering header can't override the
|
||||
// PROXY-recovered IP (xray reads the header last on ws/httpupgrade/xhttp).
|
||||
setFieldValue(['streamSettings', 'sockopt', 'trustedXForwardedFor'], []);
|
||||
setFieldValue(['streamSettings', 'sockopt', 'acceptProxyProtocol'], true);
|
||||
if (transportField) setFieldValue(['streamSettings', transportField, 'acceptProxyProtocol'], true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form.Item
|
||||
noStyle
|
||||
@ -33,6 +89,89 @@ export default function SockoptForm({
|
||||
</Form.Item>
|
||||
{on && (
|
||||
<>
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prev, curr) => {
|
||||
type ProxyWatch = {
|
||||
streamSettings?: {
|
||||
sockopt?: { trustedXForwardedFor?: unknown; acceptProxyProtocol?: unknown };
|
||||
tcpSettings?: { acceptProxyProtocol?: unknown };
|
||||
wsSettings?: { acceptProxyProtocol?: unknown };
|
||||
httpupgradeSettings?: { acceptProxyProtocol?: unknown };
|
||||
};
|
||||
};
|
||||
const pick = (v: ProxyWatch) => {
|
||||
const s = v.streamSettings;
|
||||
return JSON.stringify([
|
||||
s?.sockopt?.trustedXForwardedFor,
|
||||
s?.sockopt?.acceptProxyProtocol,
|
||||
s?.tcpSettings?.acceptProxyProtocol,
|
||||
s?.wsSettings?.acceptProxyProtocol,
|
||||
s?.httpupgradeSettings?.acceptProxyProtocol,
|
||||
]);
|
||||
};
|
||||
return pick(prev as ProxyWatch) !== pick(curr as ProxyWatch);
|
||||
}}
|
||||
>
|
||||
{({ getFieldValue, setFieldValue }) => {
|
||||
const sockopt = (getFieldValue(['streamSettings', 'sockopt']) ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const transportField = TRANSPORT_PROXY_FIELD[network];
|
||||
const transportPP = transportField
|
||||
? getFieldValue(['streamSettings', transportField, 'acceptProxyProtocol']) === true
|
||||
: false;
|
||||
const proxyOn = sockopt.acceptProxyProtocol === true || transportPP;
|
||||
const trusted = Array.isArray(sockopt.trustedXForwardedFor)
|
||||
? (sockopt.trustedXForwardedFor as string[])
|
||||
: [];
|
||||
const value: RealClientIpPreset = proxyOn
|
||||
? 'proxy'
|
||||
: trusted.length > 0
|
||||
? 'cloudflare'
|
||||
: 'off';
|
||||
const trustedMismatch =
|
||||
trusted.length > 0 && !TRUSTED_HEADER_NETWORKS.includes(network);
|
||||
const proxyMismatch = proxyOn && network === 'kcp';
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('pages.inbounds.form.realClientIp')}
|
||||
tooltip={t('pages.inbounds.form.realClientIpHint')}
|
||||
>
|
||||
<Segmented
|
||||
value={value}
|
||||
onChange={(v) =>
|
||||
applyRealClientIpPreset(v as RealClientIpPreset, getFieldValue, setFieldValue)
|
||||
}
|
||||
options={[
|
||||
{ value: 'off', label: t('pages.inbounds.form.realClientIpPresetOff') },
|
||||
{ value: 'cloudflare', label: t('pages.inbounds.form.realClientIpPresetCloudflare') },
|
||||
{ value: 'proxy', label: t('pages.inbounds.form.realClientIpPresetProxyProtocol') },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
{trustedMismatch && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message={t('pages.inbounds.form.realClientIpTrustedHeaderTransportWarn')}
|
||||
/>
|
||||
)}
|
||||
{proxyMismatch && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message={t('pages.inbounds.form.realClientIpProxyProtocolTransportWarn')}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
<Form.Item name={['streamSettings', 'sockopt', 'mark']} label={t('pages.inbounds.form.routeMark')}>
|
||||
<InputNumber min={0} />
|
||||
</Form.Item>
|
||||
@ -67,6 +206,7 @@ export default function SockoptForm({
|
||||
<Form.Item
|
||||
name={['streamSettings', 'sockopt', 'acceptProxyProtocol']}
|
||||
label={t('pages.inbounds.form.proxyProtocol')}
|
||||
tooltip={t('pages.inbounds.form.proxyProtocolHint')}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
@ -139,6 +279,7 @@ export default function SockoptForm({
|
||||
<Form.Item
|
||||
name={['streamSettings', 'sockopt', 'trustedXForwardedFor']}
|
||||
label={t('pages.inbounds.form.trustedXForwardedFor')}
|
||||
tooltip={t('pages.inbounds.form.trustedXForwardedForHint')}
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
|
||||
137
frontend/src/pages/settings/EmailTab.tsx
Normal file
137
frontend/src/pages/settings/EmailTab.tsx
Normal file
@ -0,0 +1,137 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Button, Input, InputNumber, Select, Space, Switch, Tabs } from 'antd';
|
||||
import { MailOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import { HttpUtil } from '@/utils';
|
||||
import type { AllSetting } from '@/models/setting';
|
||||
import { SettingListItem, EventBusCheckboxes } from '@/components/ui';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { catTabLabel } from './catTabLabel';
|
||||
|
||||
interface EmailTabProps {
|
||||
allSetting: AllSetting;
|
||||
updateSetting: (patch: Partial<AllSetting>) => void;
|
||||
}
|
||||
|
||||
interface SmtpTestResult {
|
||||
success: boolean;
|
||||
stage?: string;
|
||||
msg: string;
|
||||
}
|
||||
|
||||
export default function EmailTab({ allSetting, updateSetting }: EmailTabProps) {
|
||||
const { t } = useTranslation();
|
||||
const { isMobile } = useMediaQuery();
|
||||
const [testLoading, setTestLoading] = useState(false);
|
||||
const [testResult, setTestResult] = useState<SmtpTestResult | null>(null);
|
||||
|
||||
const stageLabel: Record<string, string> = {
|
||||
connect: t('pages.settings.smtpStageConnect'),
|
||||
auth: t('pages.settings.smtpStageAuth'),
|
||||
send: t('pages.settings.smtpStageSend'),
|
||||
};
|
||||
|
||||
async function handleTestSmtp() {
|
||||
setTestLoading(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const res = await HttpUtil.post('/panel/api/setting/testSmtp') as SmtpTestResult;
|
||||
setTestResult(res);
|
||||
} catch (e: unknown) {
|
||||
setTestResult({ success: false, msg: e instanceof Error ? e.message : t('pages.settings.requestFailed') });
|
||||
} finally {
|
||||
setTestLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs defaultActiveKey="1" items={[
|
||||
{
|
||||
key: '1',
|
||||
label: catTabLabel(<SettingOutlined />, t('pages.settings.smtpSettings'), isMobile),
|
||||
children: (
|
||||
<>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.smtpEnable')} description={t('pages.settings.smtpEnableDesc')}>
|
||||
<Switch checked={allSetting.smtpEnable} onChange={(v) => updateSetting({ smtpEnable: v })} />
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small" title={t('pages.settings.smtpHost')} description={t('pages.settings.smtpHostDesc')}>
|
||||
<Input value={allSetting.smtpHost} placeholder="smtp.gmail.com"
|
||||
onChange={(e) => updateSetting({ smtpHost: e.target.value })} />
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small" title={t('pages.settings.smtpPort')} description={t('pages.settings.smtpPortDesc')}>
|
||||
<InputNumber value={allSetting.smtpPort} min={1} max={65535} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ smtpPort: Number(v) || 587 })} />
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small" title={t('pages.settings.smtpUsername')} description={t('pages.settings.smtpUsernameDesc')}>
|
||||
<Input value={allSetting.smtpUsername} placeholder="user@gmail.com"
|
||||
onChange={(e) => updateSetting({ smtpUsername: e.target.value })} />
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small" title={t('pages.settings.smtpPassword')}
|
||||
description={allSetting.hasSmtpPassword ? t('pages.settings.smtpPasswordConfigured') : t('pages.settings.smtpPasswordDesc')}>
|
||||
<Input.Password value={allSetting.smtpPassword}
|
||||
placeholder={allSetting.hasSmtpPassword ? t('pages.settings.smtpPasswordPlaceholder') : ''}
|
||||
onChange={(e) => updateSetting({ smtpPassword: e.target.value })} />
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small" title={t('pages.settings.smtpTo')} description={t('pages.settings.smtpToDesc')}>
|
||||
<Input value={allSetting.smtpTo} placeholder="admin@example.com, ops@example.com"
|
||||
onChange={(e) => updateSetting({ smtpTo: e.target.value })} />
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small" title={t('pages.settings.smtpEncryption')} description={t('pages.settings.smtpEncryptionDesc')}>
|
||||
<Select
|
||||
value={allSetting.smtpEncryptionType}
|
||||
onChange={(v) => updateSetting({ smtpEncryptionType: v })}
|
||||
options={[
|
||||
{ value: 'none', label: t('pages.settings.smtpEncryptionNone') },
|
||||
{ value: 'starttls', label: t('pages.settings.smtpEncryptionStartTLS') },
|
||||
{ value: 'tls', label: t('pages.settings.smtpEncryptionTLS') },
|
||||
]}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
<Space orientation="vertical" size={8} style={{ width: '100%', marginTop: 16 }}>
|
||||
<Button type="primary" icon={<SendOutlined />} loading={testLoading} onClick={handleTestSmtp}>
|
||||
{t('pages.settings.testSmtp')}
|
||||
</Button>
|
||||
{testResult && (
|
||||
<Alert
|
||||
type={testResult.success ? 'success' : 'error'}
|
||||
message={
|
||||
testResult.success
|
||||
? t('pages.settings.' + testResult.msg)
|
||||
: <span><b>{stageLabel[testResult.stage || ''] || testResult.stage}:</b> {t('pages.settings.' + testResult.msg)}</span>
|
||||
}
|
||||
showIcon
|
||||
closable
|
||||
onClose={() => setTestResult(null)}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
label: catTabLabel(<MailOutlined />, t('pages.settings.emailNotifications'), isMobile),
|
||||
children: (
|
||||
<>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.smtpEventBusNotify')} description={t('pages.settings.smtpEventBusNotifyDesc')}>
|
||||
<EventBusCheckboxes
|
||||
value={allSetting.smtpEnabledEvents}
|
||||
onChange={(v) => updateSetting({ smtpEnabledEvents: v })}
|
||||
extra={{ 'cpu.high': { key: 'smtpCpu', value: allSetting.smtpCpu } }}
|
||||
onExtraChange={(key, v) => updateSetting({ [key]: Number(v) || 0 })}
|
||||
/>
|
||||
</SettingListItem>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]} />
|
||||
);
|
||||
}
|
||||
@ -26,6 +26,7 @@ import AppSidebar from '@/layouts/AppSidebar';
|
||||
import GeneralTab from './GeneralTab';
|
||||
import SecurityTab from './SecurityTab';
|
||||
import TelegramTab from './TelegramTab';
|
||||
import EmailTab from './EmailTab';
|
||||
import SubscriptionGeneralTab from './SubscriptionGeneralTab';
|
||||
import SubscriptionFormatsTab from './SubscriptionFormatsTab';
|
||||
import './SettingsPage.css';
|
||||
@ -34,7 +35,7 @@ interface ApiMsg {
|
||||
success?: boolean;
|
||||
}
|
||||
|
||||
const tabSlugs = ['general', 'security', 'telegram', 'subscription', 'subscription-formats'];
|
||||
const tabSlugs = ['general', 'security', 'telegram', 'email', 'subscription', 'subscription-formats'];
|
||||
|
||||
function isIp(h: string): boolean {
|
||||
if (typeof h !== 'string') return false;
|
||||
@ -197,6 +198,7 @@ export default function SettingsPage() {
|
||||
switch (activeSlug) {
|
||||
case 'security': return <SecurityTab allSetting={allSetting} updateSetting={updateSetting} />;
|
||||
case 'telegram': return <TelegramTab allSetting={allSetting} updateSetting={updateSetting} />;
|
||||
case 'email': return <EmailTab allSetting={allSetting} updateSetting={updateSetting} />;
|
||||
case 'subscription': return <SubscriptionGeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
|
||||
case 'subscription-formats': return <SubscriptionFormatsTab allSetting={allSetting} updateSetting={updateSetting} />;
|
||||
default: return <GeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Input, InputNumber, Select, Space, Switch, Tabs } from 'antd';
|
||||
import { BellOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import { Alert, Button, Input, InputNumber, Select, Space, Switch, Tabs } from 'antd';
|
||||
import { BellOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import { LanguageManager } from '@/utils';
|
||||
import { HttpUtil } from '@/utils';
|
||||
import type { AllSetting } from '@/models/setting';
|
||||
import { SettingListItem } from '@/components/ui';
|
||||
import { SettingListItem, EventBusCheckboxes } from '@/components/ui';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { catTabLabel } from './catTabLabel';
|
||||
|
||||
@ -107,7 +108,7 @@ function NotifyTimeField({ value, onChange }: { value: string; onChange: (v: str
|
||||
];
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
<Space orientation="vertical" size="small" style={{ width: '100%' }}>
|
||||
<Select<Mode>
|
||||
style={{ width: '100%' }}
|
||||
value={state.mode}
|
||||
@ -144,6 +145,21 @@ function NotifyTimeField({ value, onChange }: { value: string; onChange: (v: str
|
||||
export default function TelegramTab({ allSetting, updateSetting }: TelegramTabProps) {
|
||||
const { t } = useTranslation();
|
||||
const { isMobile } = useMediaQuery();
|
||||
const [testLoading, setTestLoading] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; msg: string } | null>(null);
|
||||
|
||||
async function handleTestTgBot() {
|
||||
setTestLoading(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const res = await HttpUtil.post('/panel/api/setting/testTgBot') as { success?: boolean; msg?: string };
|
||||
setTestResult({ success: !!res.success, msg: res.msg || '' });
|
||||
} catch (e: unknown) {
|
||||
setTestResult({ success: false, msg: e instanceof Error ? e.message : t('pages.settings.requestFailed') });
|
||||
} finally {
|
||||
setTestLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const langOptions = useMemo(
|
||||
() => LanguageManager.supportedLanguages.map((l: { value: string; name: string; icon: string }) => ({
|
||||
@ -172,11 +188,11 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.telegramToken')}
|
||||
description={allSetting.hasTgBotToken ? 'Configured; leave blank to keep current token.' : t('pages.settings.telegramTokenDesc')}
|
||||
description={allSetting.hasTgBotToken ? t('pages.settings.telegramTokenConfigured') : t('pages.settings.telegramTokenDesc')}
|
||||
>
|
||||
<Input.Password
|
||||
value={allSetting.tgBotToken}
|
||||
placeholder={allSetting.hasTgBotToken ? 'Configured - enter a new token to replace' : ''}
|
||||
placeholder={allSetting.hasTgBotToken ? t('pages.settings.telegramTokenPlaceholder') : ''}
|
||||
onChange={(e) => updateSetting({ tgBotToken: e.target.value })}
|
||||
/>
|
||||
</SettingListItem>
|
||||
@ -198,6 +214,21 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr
|
||||
<Input value={allSetting.tgBotAPIServer} placeholder="https://api.example.com"
|
||||
onChange={(e) => updateSetting({ tgBotAPIServer: e.target.value })} />
|
||||
</SettingListItem>
|
||||
|
||||
<Space orientation="vertical" size={8} style={{ width: '100%', marginTop: 16 }}>
|
||||
<Button type="primary" icon={<SendOutlined />} loading={testLoading} onClick={handleTestTgBot}>
|
||||
{t('pages.settings.testTgBot')}
|
||||
</Button>
|
||||
{testResult && (
|
||||
<Alert
|
||||
type={testResult.success ? 'success' : 'error'}
|
||||
message={testResult.msg}
|
||||
showIcon
|
||||
closable
|
||||
onClose={() => setTestResult(null)}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
</>
|
||||
),
|
||||
},
|
||||
@ -212,12 +243,14 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr
|
||||
<SettingListItem paddings="small" title={t('pages.settings.tgNotifyBackup')} description={t('pages.settings.tgNotifyBackupDesc')}>
|
||||
<Switch checked={allSetting.tgBotBackup} onChange={(v) => updateSetting({ tgBotBackup: v })} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.tgNotifyLogin')} description={t('pages.settings.tgNotifyLoginDesc')}>
|
||||
<Switch checked={allSetting.tgBotLoginNotify} onChange={(v) => updateSetting({ tgBotLoginNotify: v })} />
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.tgNotifyCpu')} description={t('pages.settings.tgNotifyCpuDesc')}>
|
||||
<InputNumber value={allSetting.tgCpu} min={0} max={100} style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ tgCpu: Number(v) || 0 })} />
|
||||
|
||||
<SettingListItem paddings="small" title={t('pages.settings.tgEventBusNotify')} description={t('pages.settings.tgEventBusNotifyDesc')}>
|
||||
<EventBusCheckboxes
|
||||
value={allSetting.tgEnabledEvents}
|
||||
onChange={(v) => updateSetting({ tgEnabledEvents: v })}
|
||||
extra={{ 'cpu.high': { key: 'tgCpu', value: allSetting.tgCpu } }}
|
||||
onExtraChange={(key, v) => updateSetting({ [key]: Number(v) || 0 })}
|
||||
/>
|
||||
</SettingListItem>
|
||||
</>
|
||||
),
|
||||
|
||||
@ -26,7 +26,6 @@ export const AllSettingSchema = z.object({
|
||||
tgBotChatId: z.string().optional(),
|
||||
tgRunTime: z.string().optional(),
|
||||
tgBotBackup: z.boolean().optional(),
|
||||
tgBotLoginNotify: z.boolean().optional(),
|
||||
tgCpu: z.number().int().min(0).max(100).optional(),
|
||||
tgLang: z.string().optional(),
|
||||
twoFactorEnable: z.boolean().optional(),
|
||||
@ -91,6 +90,7 @@ export const AllSettingSchema = z.object({
|
||||
hasApiToken: z.boolean().optional(),
|
||||
hasWarpSecret: z.boolean().optional(),
|
||||
hasNordSecret: z.boolean().optional(),
|
||||
hasSmtpPassword: z.boolean().optional(),
|
||||
}).loose();
|
||||
|
||||
export type AllSettingInput = z.infer<typeof AllSettingSchema>;
|
||||
|
||||
@ -80,6 +80,7 @@ exports[`inbound transport forms > RawForm field structure is stable 1`] = `
|
||||
exports[`inbound transport forms > SockoptForm field structure is stable (enabled + happy eyeballs) 1`] = `
|
||||
[
|
||||
"Sockopt",
|
||||
"Real client IP",
|
||||
"Route Mark",
|
||||
"TCP Keep Alive Interval",
|
||||
"TCP Keep Alive Idle",
|
||||
|
||||
@ -89,7 +89,7 @@ describe('inbound transport forms', () => {
|
||||
|
||||
it('SockoptForm field structure is stable (enabled + happy eyeballs)', () => {
|
||||
renderInForm(
|
||||
() => <SockoptForm toggleSockopt={noop} />,
|
||||
() => <SockoptForm toggleSockopt={noop} network="tcp" />,
|
||||
{ streamSettings: { sockopt: { happyEyeballs: {} } } },
|
||||
);
|
||||
expect(fieldLabels()).toMatchSnapshot();
|
||||
|
||||
2
go.mod
2
go.mod
@ -13,7 +13,7 @@ require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/mymmrac/telego v1.9.0
|
||||
github.com/mymmrac/telego v1.10.0
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1
|
||||
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
|
||||
4
go.sum
4
go.sum
@ -138,8 +138,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/mymmrac/telego v1.9.0 h1:ZUJxZaPx/1IgRvVb5lXnUB8FgW5rNYfRe6Q2EJ4OJ+Y=
|
||||
github.com/mymmrac/telego v1.9.0/go.mod h1:tVEB7OqiOPx8elRk9+ETkwiDQrUhWSB2XmAKIY9KmWY=
|
||||
github.com/mymmrac/telego v1.10.0 h1:Upe0TqYyiK+yE5RFXXuQWVHGfLZnqvUfj4KZVjTcgWE=
|
||||
github.com/mymmrac/telego v1.10.0/go.mod h1:LsQKDA6EwssPP9XkORPXwwOFUGIRf/Wf2Wb8y3YyJdE=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
|
||||
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88=
|
||||
|
||||
@ -73,6 +73,7 @@ func initModels() error {
|
||||
&model.ClientGroup{},
|
||||
&model.InboundFallback{},
|
||||
&model.NodeClientTraffic{},
|
||||
&model.NodeClientIp{},
|
||||
&model.ClientGlobalTraffic{},
|
||||
&model.OutboundSubscription{},
|
||||
}
|
||||
|
||||
@ -50,6 +50,7 @@ func migrationModels() []any {
|
||||
&model.ClientExternalLink{},
|
||||
&model.InboundFallback{},
|
||||
&model.NodeClientTraffic{},
|
||||
&model.NodeClientIp{},
|
||||
&model.OutboundSubscription{},
|
||||
}
|
||||
}
|
||||
|
||||
27
internal/database/model/node_client_ip.go
Normal file
27
internal/database/model/node_client_ip.go
Normal file
@ -0,0 +1,27 @@
|
||||
package model
|
||||
|
||||
// ClientIpEntry is the wire/JSON shape of a single observed client IP with the
|
||||
// last time it was seen (unix seconds). It mirrors job.IPWithTimestamp and the
|
||||
// service-internal clientIpEntry so the per-node attribution blob round-trips
|
||||
// with the existing inbound_client_ips storage.
|
||||
type ClientIpEntry struct {
|
||||
IP string `json:"ip"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
// NodeClientIp records which panel (identified by its stable panelGuid) observed
|
||||
// a client's IPs on its own Xray. Unlike InboundClientIps (a flattened,
|
||||
// cluster-wide union used for IP-limit counting and that is pushed back to every
|
||||
// node), this table preserves attribution: it never mixes in IPs a parent pushed
|
||||
// down, so the master can tell exactly which node a given IP is connecting to.
|
||||
//
|
||||
// Rows under the local panel's own panelGuid are written by check_client_ip_job
|
||||
// from local Xray observations; rows under remote guids are merged in by the node
|
||||
// sync job from each node's clientIpsByGuid report (its own panelGuid subtree plus
|
||||
// any descendants), so attribution survives across a chain of nodes.
|
||||
type NodeClientIp struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
NodeGuid string `json:"nodeGuid" gorm:"uniqueIndex:idx_nodeip_guid_email,priority:1;not null"`
|
||||
Email string `json:"email" gorm:"uniqueIndex:idx_nodeip_guid_email,priority:2;not null"`
|
||||
Ips string `json:"ips"`
|
||||
}
|
||||
123
internal/eventbus/bus.go
Normal file
123
internal/eventbus/bus.go
Normal file
@ -0,0 +1,123 @@
|
||||
package eventbus
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
)
|
||||
|
||||
// DefaultBufferSize is the number of events the bus can hold before Publish starts dropping.
|
||||
const DefaultBufferSize = 256
|
||||
|
||||
// subscriber pairs an ID with its event handler.
|
||||
type subscriber struct {
|
||||
id string
|
||||
handler func(Event)
|
||||
}
|
||||
|
||||
// Bus is a minimal in-process pub/sub event bus backed by a buffered channel.
|
||||
// Producers call Publish (non-blocking) and every event is fanned out to all
|
||||
// subscribers; per-event filtering is the subscriber's responsibility.
|
||||
type Bus struct {
|
||||
ch chan Event
|
||||
subs []subscriber
|
||||
mu sync.RWMutex
|
||||
done chan struct{}
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// New creates a Bus with the given buffer size. Use 0 for DefaultBufferSize.
|
||||
func New(bufSize int) *Bus {
|
||||
if bufSize <= 0 {
|
||||
bufSize = DefaultBufferSize
|
||||
}
|
||||
b := &Bus{
|
||||
ch: make(chan Event, bufSize),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
b.wg.Add(1)
|
||||
go b.dispatch()
|
||||
return b
|
||||
}
|
||||
|
||||
// Subscribe registers a handler that receives every published event.
|
||||
// The id is used for Unsubscribe; it must be unique across active subscribers.
|
||||
// Subscribing with an already-registered id replaces the previous handler.
|
||||
func (b *Bus) Subscribe(id string, handler func(Event)) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
for i, s := range b.subs {
|
||||
if s.id == id {
|
||||
b.subs[i].handler = handler
|
||||
return
|
||||
}
|
||||
}
|
||||
b.subs = append(b.subs, subscriber{id: id, handler: handler})
|
||||
}
|
||||
|
||||
// Unsubscribe removes a subscriber by id. Safe to call with unknown id.
|
||||
func (b *Bus) Unsubscribe(id string) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
for i, s := range b.subs {
|
||||
if s.id == id {
|
||||
b.subs = append(b.subs[:i], b.subs[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Publish sends an event to all subscribers. Non-blocking — if the buffer is
|
||||
// full the event is dropped and a warning is logged.
|
||||
func (b *Bus) Publish(e Event) {
|
||||
if e.Timestamp.IsZero() {
|
||||
e.Timestamp = time.Now()
|
||||
}
|
||||
select {
|
||||
case b.ch <- e:
|
||||
default:
|
||||
logger.Warning("eventbus: buffer full, dropping event ", e.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// dispatch is the fan-out loop. It reads events from the channel and calls
|
||||
// every subscriber's handler sequentially. Handlers run on the dispatch
|
||||
// goroutine — they must not block.
|
||||
func (b *Bus) dispatch() {
|
||||
defer b.wg.Done()
|
||||
for {
|
||||
select {
|
||||
case e, ok := <-b.ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
b.mu.RLock()
|
||||
subs := make([]subscriber, len(b.subs))
|
||||
copy(subs, b.subs)
|
||||
b.mu.RUnlock()
|
||||
for _, s := range subs {
|
||||
safeCall(s.handler, e)
|
||||
}
|
||||
case <-b.done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// safeCall invokes handler with panic recovery.
|
||||
func safeCall(fn func(Event), e Event) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logger.Errorf("eventbus: subscriber panicked on %s: %v", e.Type, r)
|
||||
}
|
||||
}()
|
||||
fn(e)
|
||||
}
|
||||
|
||||
// Stop shuts down the bus: the dispatch goroutine exits, in-flight handlers
|
||||
// finish, and any events still buffered may be dropped. Safe to call once.
|
||||
func (b *Bus) Stop() {
|
||||
close(b.done)
|
||||
b.wg.Wait()
|
||||
}
|
||||
199
internal/eventbus/bus_test.go
Normal file
199
internal/eventbus/bus_test.go
Normal file
@ -0,0 +1,199 @@
|
||||
package eventbus
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/op/go-logging"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
logger.InitLogger(logging.ERROR)
|
||||
m.Run()
|
||||
}
|
||||
|
||||
func TestBusPublishSubscribe(t *testing.T) {
|
||||
b := New(16)
|
||||
defer b.Stop()
|
||||
|
||||
var received Event
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
|
||||
b.Subscribe("test", func(e Event) {
|
||||
received = e
|
||||
wg.Done()
|
||||
})
|
||||
|
||||
b.Publish(Event{Type: EventOutboundDown, Source: "my-proxy"})
|
||||
|
||||
select {
|
||||
case <-waitDone(&wg):
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("subscriber did not receive event")
|
||||
}
|
||||
|
||||
if received.Type != EventOutboundDown {
|
||||
t.Errorf("got type %q, want %q", received.Type, EventOutboundDown)
|
||||
}
|
||||
if received.Source != "my-proxy" {
|
||||
t.Errorf("got source %q, want %q", received.Source, "my-proxy")
|
||||
}
|
||||
if received.Timestamp.IsZero() {
|
||||
t.Error("timestamp not set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusMultipleSubscribers(t *testing.T) {
|
||||
b := New(16)
|
||||
defer b.Stop()
|
||||
|
||||
var count atomic.Int32
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
|
||||
b.Subscribe("a", func(e Event) {
|
||||
count.Add(1)
|
||||
wg.Done()
|
||||
})
|
||||
b.Subscribe("b", func(e Event) {
|
||||
count.Add(1)
|
||||
wg.Done()
|
||||
})
|
||||
|
||||
b.Publish(Event{Type: EventXrayCrash})
|
||||
|
||||
select {
|
||||
case <-waitDone(&wg):
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("subscribers did not receive event")
|
||||
}
|
||||
|
||||
if count.Load() != 2 {
|
||||
t.Errorf("got %d calls, want 2", count.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusUnsubscribe(t *testing.T) {
|
||||
b := New(16)
|
||||
defer b.Stop()
|
||||
|
||||
var count atomic.Int32
|
||||
|
||||
b.Subscribe("test", func(e Event) {
|
||||
count.Add(1)
|
||||
})
|
||||
b.Unsubscribe("test")
|
||||
|
||||
b.Publish(Event{Type: EventOutboundUp})
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
if count.Load() != 0 {
|
||||
t.Errorf("got %d calls after unsubscribe, want 0", count.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusReplaceSubscriber(t *testing.T) {
|
||||
b := New(16)
|
||||
defer b.Stop()
|
||||
|
||||
var last string
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
|
||||
b.Subscribe("test", func(e Event) {
|
||||
last = "old"
|
||||
})
|
||||
b.Subscribe("test", func(e Event) {
|
||||
last = "new"
|
||||
wg.Done()
|
||||
})
|
||||
|
||||
b.Publish(Event{Type: EventOutboundDown})
|
||||
|
||||
select {
|
||||
case <-waitDone(&wg):
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("subscriber did not receive event")
|
||||
}
|
||||
|
||||
if last != "new" {
|
||||
t.Errorf("got %q, want %q", last, "new")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusPanicRecovery(t *testing.T) {
|
||||
b := New(16)
|
||||
defer b.Stop()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
|
||||
b.Subscribe("panicker", func(e Event) {
|
||||
panic("oops")
|
||||
})
|
||||
b.Subscribe("after", func(e Event) {
|
||||
wg.Done()
|
||||
})
|
||||
|
||||
b.Publish(Event{Type: EventOutboundDown})
|
||||
|
||||
select {
|
||||
case <-waitDone(&wg):
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("subscriber after panicker did not receive event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusBufferFull(t *testing.T) {
|
||||
b := New(2)
|
||||
defer b.Stop()
|
||||
|
||||
b.Subscribe("slow", func(e Event) {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
})
|
||||
|
||||
b.Publish(Event{Type: EventOutboundDown})
|
||||
b.Publish(Event{Type: EventOutboundUp})
|
||||
b.Publish(Event{Type: EventXrayCrash})
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
|
||||
func TestBusZeroTimestamp(t *testing.T) {
|
||||
b := New(16)
|
||||
defer b.Stop()
|
||||
|
||||
var received Event
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
|
||||
b.Subscribe("test", func(e Event) {
|
||||
received = e
|
||||
wg.Done()
|
||||
})
|
||||
|
||||
b.Publish(Event{Type: EventOutboundDown})
|
||||
|
||||
select {
|
||||
case <-waitDone(&wg):
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("subscriber did not receive event")
|
||||
}
|
||||
|
||||
if received.Timestamp.IsZero() {
|
||||
t.Error("timestamp should be set automatically")
|
||||
}
|
||||
}
|
||||
|
||||
func waitDone(wg *sync.WaitGroup) <-chan struct{} {
|
||||
ch := make(chan struct{})
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(ch)
|
||||
}()
|
||||
return ch
|
||||
}
|
||||
64
internal/eventbus/events.go
Normal file
64
internal/eventbus/events.go
Normal file
@ -0,0 +1,64 @@
|
||||
package eventbus
|
||||
|
||||
import "time"
|
||||
|
||||
// EventType identifies the kind of event flowing through the bus.
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
// Outbound health (observatory-driven)
|
||||
EventOutboundDown EventType = "outbound.down"
|
||||
EventOutboundUp EventType = "outbound.up"
|
||||
|
||||
// Xray core (local)
|
||||
EventXrayCrash EventType = "xray.crash"
|
||||
|
||||
// Node health (heartbeat-driven)
|
||||
EventNodeDown EventType = "node.down"
|
||||
EventNodeUp EventType = "node.up"
|
||||
|
||||
// System health
|
||||
EventCPUHigh EventType = "cpu.high"
|
||||
|
||||
// Security
|
||||
EventLoginAttempt EventType = "login.attempt"
|
||||
)
|
||||
|
||||
// Event is the unit of information flowing through the bus.
|
||||
type Event struct {
|
||||
Type EventType
|
||||
Source string // outbound tag, node name, client email, IP, etc.
|
||||
Data any // event-specific payload, may be nil
|
||||
Timestamp time.Time // when the event was detected
|
||||
}
|
||||
|
||||
// OutboundHealthData carries observatory details for outbound events.
|
||||
type OutboundHealthData struct {
|
||||
Delay int64 // last measured delay in ms, 0 if unknown
|
||||
Error string // last error if probe failed, empty if up
|
||||
}
|
||||
|
||||
// NodeHealthData carries heartbeat details for node events.
|
||||
type NodeHealthData struct {
|
||||
NodeId int
|
||||
LatencyMs int
|
||||
CpuPct float64
|
||||
MemPct float64
|
||||
XrayState string // "running", "stopped", etc.
|
||||
XrayError string
|
||||
}
|
||||
|
||||
// LoginEventData carries login attempt details.
|
||||
type LoginEventData struct {
|
||||
Username string
|
||||
IP string
|
||||
Time string
|
||||
Status string // "success" or "fail"
|
||||
Reason string
|
||||
}
|
||||
|
||||
// SystemMetricData carries raw system metric values for threshold-based events.
|
||||
type SystemMetricData struct {
|
||||
Percent float64 // current usage percentage
|
||||
Threshold int // configured threshold
|
||||
}
|
||||
33
internal/eventbus/filter.go
Normal file
33
internal/eventbus/filter.go
Normal file
@ -0,0 +1,33 @@
|
||||
package eventbus
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RateLimiter prevents notification spam from flapping events.
|
||||
type RateLimiter struct {
|
||||
mu sync.Mutex
|
||||
lastSent map[string]time.Time
|
||||
cooldown time.Duration
|
||||
}
|
||||
|
||||
// NewRateLimiter creates a rate limiter with the given cooldown period.
|
||||
func NewRateLimiter(cooldown time.Duration) *RateLimiter {
|
||||
return &RateLimiter{
|
||||
lastSent: make(map[string]time.Time),
|
||||
cooldown: cooldown,
|
||||
}
|
||||
}
|
||||
|
||||
// Allow returns true if the event should be sent (cooldown has elapsed).
|
||||
func (r *RateLimiter) Allow(eventType EventType, source string) bool {
|
||||
key := string(eventType) + ":" + source
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if time.Since(r.lastSent[key]) < r.cooldown {
|
||||
return false
|
||||
}
|
||||
r.lastSent[key] = time.Now()
|
||||
return true
|
||||
}
|
||||
@ -1,11 +1,8 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
@ -74,6 +71,7 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
|
||||
g.POST("/clearIps/:email", a.clearIps)
|
||||
g.POST("/onlines", a.onlines)
|
||||
g.POST("/onlinesByGuid", a.onlinesByGuid)
|
||||
g.POST("/clientIpsByGuid", a.clientIpsByGuid)
|
||||
g.POST("/activeInbounds", a.activeInbounds)
|
||||
g.POST("/lastOnline", a.lastOnline)
|
||||
}
|
||||
@ -402,38 +400,13 @@ func (a *ClientController) updateTrafficByEmail(c *gin.Context) {
|
||||
|
||||
func (a *ClientController) getIps(c *gin.Context) {
|
||||
email := c.Param("email")
|
||||
ips, err := a.inboundService.GetInboundClientIps(email)
|
||||
if err != nil || ips == "" {
|
||||
jsonObj(c, "No IP Record", nil)
|
||||
return
|
||||
}
|
||||
type ipWithTimestamp struct {
|
||||
IP string `json:"ip"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
var ipsWithTime []ipWithTimestamp
|
||||
if err := json.Unmarshal([]byte(ips), &ipsWithTime); err == nil && len(ipsWithTime) > 0 {
|
||||
formatted := make([]string, 0, len(ipsWithTime))
|
||||
for _, item := range ipsWithTime {
|
||||
if item.IP == "" {
|
||||
continue
|
||||
}
|
||||
if item.Timestamp > 0 {
|
||||
ts := time.Unix(item.Timestamp, 0).Local().Format("2006-01-02 15:04:05")
|
||||
formatted = append(formatted, fmt.Sprintf("%s (%s)", item.IP, ts))
|
||||
continue
|
||||
}
|
||||
formatted = append(formatted, item.IP)
|
||||
}
|
||||
jsonObj(c, formatted, nil)
|
||||
return
|
||||
}
|
||||
var oldIps []string
|
||||
if err := json.Unmarshal([]byte(ips), &oldIps); err == nil && len(oldIps) > 0 {
|
||||
jsonObj(c, oldIps, nil)
|
||||
return
|
||||
}
|
||||
jsonObj(c, ips, nil)
|
||||
infos, err := a.inboundService.GetClientIpsWithNodes(email)
|
||||
jsonObj(c, infos, err)
|
||||
}
|
||||
|
||||
func (a *ClientController) clientIpsByGuid(c *gin.Context) {
|
||||
data, err := a.inboundService.GetClientIpsByGuid()
|
||||
jsonObj(c, data, err)
|
||||
}
|
||||
|
||||
func (a *ClientController) clearIps(c *gin.Context) {
|
||||
|
||||
@ -10,6 +10,7 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/email"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
|
||||
|
||||
@ -54,11 +55,14 @@ func (a *SettingController) initRouter(g *gin.RouterGroup) {
|
||||
g.POST("/apiTokens/create", a.createApiToken)
|
||||
g.POST("/apiTokens/delete/:id", a.deleteApiToken)
|
||||
g.POST("/apiTokens/setEnabled/:id", a.setApiTokenEnabled)
|
||||
g.POST("/testSmtp", a.testSmtp)
|
||||
g.POST("/testTgBot", a.testTgBot)
|
||||
}
|
||||
|
||||
// getAllSetting retrieves all current settings.
|
||||
// getAllSetting retrieves all current settings as the browser-safe view:
|
||||
// secret values are redacted and surfaced as has* presence flags instead.
|
||||
func (a *SettingController) getAllSetting(c *gin.Context) {
|
||||
allSetting, err := a.settingService.GetAllSetting()
|
||||
allSetting, err := a.settingService.GetAllSettingView()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
|
||||
return
|
||||
@ -198,3 +202,58 @@ func (a *SettingController) setApiTokenEnabled(c *gin.Context) {
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), a.apiTokenService.SetEnabled(id, form.Enabled))
|
||||
}
|
||||
|
||||
func (a *SettingController) testSmtp(c *gin.Context) {
|
||||
if emailService == nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.smtpNotInitialized"), errors.New("email service not available"))
|
||||
return
|
||||
}
|
||||
logger.Info("SMTP test: starting...")
|
||||
result := emailService.TestConnection()
|
||||
if !result.Success {
|
||||
logger.Warning("SMTP test failed at", result.Stage+":", result.Message)
|
||||
c.JSON(200, gin.H{
|
||||
"success": false,
|
||||
"stage": result.Stage,
|
||||
"msg": result.Message,
|
||||
})
|
||||
return
|
||||
}
|
||||
logger.Info("SMTP test: success")
|
||||
c.JSON(200, gin.H{
|
||||
"success": true,
|
||||
"stage": result.Stage,
|
||||
"msg": result.Message,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *SettingController) testTgBot(c *gin.Context) {
|
||||
enabled, err := a.settingService.GetTgbotEnabled()
|
||||
if err != nil || !enabled {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.tgBotNotEnabled"), errors.New("telegram bot disabled"))
|
||||
return
|
||||
}
|
||||
// Import tgbot package would create a circular dependency, so we call
|
||||
// the test through the global function registered at startup.
|
||||
if testTgFunc != nil {
|
||||
if err := testTgFunc(); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.tgTestFailed")+": "+err.Error(), err)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.tgTestSuccess"), nil)
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.tgBotNotRunning"), errors.New("bot not started"))
|
||||
}
|
||||
|
||||
// testTgFunc is set from web layer to test Telegram sending without circular imports.
|
||||
var testTgFunc func() error
|
||||
|
||||
// SetTestTgFunc registers the function used to test Telegram sending.
|
||||
func SetTestTgFunc(fn func() error) { testTgFunc = fn }
|
||||
|
||||
// emailService is set from web layer.
|
||||
var emailService *email.EmailService
|
||||
|
||||
// SetEmailService registers the email service for test endpoints.
|
||||
func SetEmailService(s *email.EmailService) { emailService = s }
|
||||
|
||||
@ -39,16 +39,27 @@ type AllSetting struct {
|
||||
Datepicker string `json:"datepicker" form:"datepicker"` // Date picker format
|
||||
|
||||
// Telegram bot settings
|
||||
TgBotEnable bool `json:"tgBotEnable" form:"tgBotEnable"` // Enable Telegram bot notifications
|
||||
TgBotToken string `json:"tgBotToken" form:"tgBotToken"` // Telegram bot token
|
||||
TgBotProxy string `json:"tgBotProxy" form:"tgBotProxy"` // Proxy URL for Telegram bot
|
||||
TgBotAPIServer string `json:"tgBotAPIServer" form:"tgBotAPIServer"` // Custom API server for Telegram bot
|
||||
TgBotChatId string `json:"tgBotChatId" form:"tgBotChatId"` // Telegram chat ID for notifications
|
||||
TgRunTime string `json:"tgRunTime" form:"tgRunTime"` // Cron schedule for Telegram notifications
|
||||
TgBotBackup bool `json:"tgBotBackup" form:"tgBotBackup"` // Enable database backup via Telegram
|
||||
TgBotLoginNotify bool `json:"tgBotLoginNotify" form:"tgBotLoginNotify"` // Send login notifications
|
||||
TgCpu int `json:"tgCpu" form:"tgCpu" validate:"gte=0,lte=100"` // CPU usage threshold for alerts (percent)
|
||||
TgLang string `json:"tgLang" form:"tgLang"` // Telegram bot language
|
||||
TgBotEnable bool `json:"tgBotEnable" form:"tgBotEnable"` // Enable Telegram bot notifications
|
||||
TgBotToken string `json:"tgBotToken" form:"tgBotToken"` // Telegram bot token
|
||||
TgBotProxy string `json:"tgBotProxy" form:"tgBotProxy"` // Proxy URL for Telegram bot
|
||||
TgBotAPIServer string `json:"tgBotAPIServer" form:"tgBotAPIServer"` // Custom API server for Telegram bot
|
||||
TgBotChatId string `json:"tgBotChatId" form:"tgBotChatId"` // Telegram chat ID for notifications
|
||||
TgRunTime string `json:"tgRunTime" form:"tgRunTime"` // Cron schedule for Telegram notifications
|
||||
TgBotBackup bool `json:"tgBotBackup" form:"tgBotBackup"` // Enable database backup via Telegram
|
||||
TgCpu int `json:"tgCpu" form:"tgCpu" validate:"gte=0,lte=100"` // CPU usage threshold for alerts (percent)
|
||||
TgLang string `json:"tgLang" form:"tgLang"` // Telegram bot language
|
||||
TgEnabledEvents string `json:"tgEnabledEvents" form:"tgEnabledEvents"` // Comma-separated event types to send via Telegram
|
||||
|
||||
// Email (SMTP) notification settings
|
||||
SmtpEnable bool `json:"smtpEnable" form:"smtpEnable"` // Enable email notifications
|
||||
SmtpHost string `json:"smtpHost" form:"smtpHost"` // SMTP server host
|
||||
SmtpPort int `json:"smtpPort" form:"smtpPort" validate:"gte=1,lte=65535"` // SMTP server port
|
||||
SmtpUsername string `json:"smtpUsername" form:"smtpUsername"` // SMTP username
|
||||
SmtpPassword string `json:"smtpPassword" form:"smtpPassword"` // SMTP password
|
||||
SmtpTo string `json:"smtpTo" form:"smtpTo"` // Comma-separated recipient emails
|
||||
SmtpEncryptionType string `json:"smtpEncryptionType" form:"smtpEncryptionType"` // SMTP encryption: none, starttls, tls
|
||||
SmtpEnabledEvents string `json:"smtpEnabledEvents" form:"smtpEnabledEvents"` // Comma-separated event types to send via email
|
||||
SmtpCpu int `json:"smtpCpu" form:"smtpCpu" validate:"gte=0,lte=100"` // CPU threshold for email notifications
|
||||
|
||||
// Security settings
|
||||
TimeLocation string `json:"timeLocation" form:"timeLocation"` // Time zone location
|
||||
@ -130,6 +141,7 @@ type AllSettingView struct {
|
||||
HasApiToken bool `json:"hasApiToken"`
|
||||
HasWarpSecret bool `json:"hasWarpSecret"`
|
||||
HasNordSecret bool `json:"hasNordSecret"`
|
||||
HasSmtpPassword bool `json:"hasSmtpPassword"`
|
||||
}
|
||||
|
||||
// CheckValid validates all settings in the AllSetting struct, checking IP addresses, ports, SSL certificates, and other configuration values.
|
||||
|
||||
@ -252,6 +252,10 @@ func (j *CheckClientIpJob) processLogFile(enforce bool) bool {
|
||||
// hours ago is still live even though its timestamp is old.
|
||||
func (j *CheckClientIpJob) processObserved(observed map[string]map[string]int64, enforce, observedAreLive bool) bool {
|
||||
shouldCleanLog := false
|
||||
now := time.Now().Unix()
|
||||
// attribution accumulates this scan's local observations per email so they can
|
||||
// be recorded under this panel's own guid for cross-node IP attribution.
|
||||
attribution := make(map[string][]model.ClientIpEntry, len(observed))
|
||||
for email, ipTimestamps := range observed {
|
||||
|
||||
// The observations can still reference a client that was just renamed
|
||||
@ -271,8 +275,20 @@ func (j *CheckClientIpJob) processObserved(observed map[string]map[string]int64,
|
||||
|
||||
// Convert to IPWithTimestamp slice
|
||||
ipsWithTime := make([]IPWithTimestamp, 0, len(ipTimestamps))
|
||||
attrEntries := make([]model.ClientIpEntry, 0, len(ipTimestamps))
|
||||
for ip, timestamp := range ipTimestamps {
|
||||
ipsWithTime = append(ipsWithTime, IPWithTimestamp{IP: ip, Timestamp: timestamp})
|
||||
// Live API observations may carry an old lastSeen (connection start),
|
||||
// so stamp attribution with now; otherwise the stale cutoff would evict
|
||||
// an IP that is connected right now.
|
||||
attrTs := timestamp
|
||||
if observedAreLive {
|
||||
attrTs = now
|
||||
}
|
||||
attrEntries = append(attrEntries, model.ClientIpEntry{IP: ip, Timestamp: attrTs})
|
||||
}
|
||||
if len(attrEntries) > 0 {
|
||||
attribution[email] = attrEntries
|
||||
}
|
||||
|
||||
clientIpsRecord, err := j.getInboundClientIps(email)
|
||||
@ -284,9 +300,27 @@ func (j *CheckClientIpJob) processObserved(observed map[string]map[string]int64,
|
||||
shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, inbound, email, ipsWithTime, enforce, observedAreLive) || shouldCleanLog
|
||||
}
|
||||
|
||||
j.recordLocalAttribution(attribution)
|
||||
|
||||
return shouldCleanLog
|
||||
}
|
||||
|
||||
// recordLocalAttribution stores this scan's local observations under this panel's
|
||||
// own guid so a parent panel can attribute each IP to the node it is on.
|
||||
// Best-effort: attribution is advisory and must never block IP-limit enforcement.
|
||||
func (j *CheckClientIpJob) recordLocalAttribution(attribution map[string][]model.ClientIpEntry) {
|
||||
if len(attribution) == 0 {
|
||||
return
|
||||
}
|
||||
guid, err := (&service.SettingService{}).GetPanelGuid()
|
||||
if err != nil || guid == "" {
|
||||
return
|
||||
}
|
||||
if err := (&service.InboundService{}).RecordLocalClientIps(guid, attribution); err != nil {
|
||||
logger.Debug("[LimitIP] record local ip attribution failed:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// mergeClientIps folds this scan's observations into the persisted set,
|
||||
// dropping entries older than staleCutoff. newAlwaysLive exempts the new
|
||||
// entries from that cutoff: an API-observed IP is a live connection by
|
||||
|
||||
@ -1,18 +1,16 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot"
|
||||
|
||||
"github.com/shirou/gopsutil/v4/cpu"
|
||||
)
|
||||
|
||||
// CheckCpuJob monitors CPU usage and sends Telegram notifications when usage exceeds the configured threshold.
|
||||
// CheckCpuJob monitors CPU usage and publishes events when threshold is exceeded.
|
||||
type CheckCpuJob struct {
|
||||
tgbotService tgbot.Tgbot
|
||||
settingService service.SettingService
|
||||
}
|
||||
|
||||
@ -21,21 +19,19 @@ func NewCheckCpuJob() *CheckCpuJob {
|
||||
return new(CheckCpuJob)
|
||||
}
|
||||
|
||||
// Run checks CPU usage over the last minute and sends a Telegram alert if it exceeds the threshold.
|
||||
// Run checks CPU usage and publishes a cpu.high event with raw metric data.
|
||||
func (j *CheckCpuJob) Run() {
|
||||
threshold, err := j.settingService.GetTgCpu()
|
||||
if err != nil || threshold <= 0 {
|
||||
// If threshold cannot be retrieved or is not set, skip sending notifications
|
||||
percent, err := cpu.Percent(1*time.Minute, false)
|
||||
if err != nil || len(percent) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// get latest status of server
|
||||
percent, err := cpu.Percent(1*time.Minute, false)
|
||||
if err == nil && percent[0] > float64(threshold) {
|
||||
msg := j.tgbotService.I18nBot("tgbot.messages.cpuThreshold",
|
||||
"Percent=="+strconv.FormatFloat(percent[0], 'f', 2, 64),
|
||||
"Threshold=="+strconv.Itoa(threshold))
|
||||
|
||||
j.tgbotService.SendMsgToTgbotAdmins(msg)
|
||||
if EventBus != nil {
|
||||
EventBus.Publish(eventbus.Event{
|
||||
Type: eventbus.EventCPUHigh,
|
||||
Data: &eventbus.SystemMetricData{
|
||||
Percent: percent[0],
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,10 +3,14 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
// EventBus is set from web layer to publish events.
|
||||
var EventBus *eventbus.Bus
|
||||
|
||||
// CheckXrayRunningJob monitors Xray process health and restarts it if it crashes.
|
||||
type CheckXrayRunningJob struct {
|
||||
xrayService service.XrayService
|
||||
|
||||
@ -2,10 +2,12 @@ package job
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
|
||||
@ -70,6 +72,7 @@ func (j *NodeHeartbeatJob) Run() {
|
||||
func (j *NodeHeartbeatJob) probeOne(n *model.Node) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), nodeHeartbeatRequestTimeout)
|
||||
defer cancel()
|
||||
prevStatus := n.Status
|
||||
patch, err := j.nodeService.Probe(ctx, n)
|
||||
if err != nil {
|
||||
patch.Status = "offline"
|
||||
@ -79,6 +82,7 @@ func (j *NodeHeartbeatJob) probeOne(n *model.Node) {
|
||||
if updErr := j.nodeService.UpdateHeartbeat(n.Id, patch); updErr != nil {
|
||||
logger.Warning("node heartbeat: update node", n.Id, "failed:", updErr)
|
||||
}
|
||||
publishNodeTransition(n, prevStatus, patch)
|
||||
// Learn the nodes this node manages so the panel can surface them as
|
||||
// transitive sub-nodes (#4983). Fresh context — the probe budget above may
|
||||
// be spent. Drop them when the node is unreachable.
|
||||
@ -90,3 +94,37 @@ func (j *NodeHeartbeatJob) probeOne(n *model.Node) {
|
||||
j.nodeService.ClearDescendants(n.Id)
|
||||
}
|
||||
}
|
||||
|
||||
// publishNodeTransition emits node.down / node.up only on a genuine state change.
|
||||
// An "unknown"/empty previous status (fresh start) is treated as not-online, so a
|
||||
// node coming up for the first time fires node.up but never a spurious node.down.
|
||||
func publishNodeTransition(n *model.Node, prevStatus string, patch service.HeartbeatPatch) {
|
||||
if EventBus == nil {
|
||||
return
|
||||
}
|
||||
var eventType eventbus.EventType
|
||||
switch {
|
||||
case prevStatus == "online" && patch.Status == "offline":
|
||||
eventType = eventbus.EventNodeDown
|
||||
case prevStatus != "online" && patch.Status == "online":
|
||||
eventType = eventbus.EventNodeUp
|
||||
default:
|
||||
return
|
||||
}
|
||||
source := n.Name
|
||||
if source == "" {
|
||||
source = "node-" + strconv.Itoa(n.Id)
|
||||
}
|
||||
EventBus.Publish(eventbus.Event{
|
||||
Type: eventType,
|
||||
Source: source,
|
||||
Data: &eventbus.NodeHealthData{
|
||||
NodeId: n.Id,
|
||||
LatencyMs: patch.LatencyMs,
|
||||
CpuPct: patch.CpuPct,
|
||||
MemPct: patch.MemPct,
|
||||
XrayState: patch.XrayState,
|
||||
XrayError: patch.XrayError,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -294,4 +294,15 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy
|
||||
logger.Warning("node traffic sync: push client ips to", n.Name, "failed:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Per-node IP attribution: pull the node's guid-keyed subtree (its own
|
||||
// observations plus any descendants) so the master can tell which node each
|
||||
// IP is on. Old nodes without the endpoint just return an error — skip them.
|
||||
if guidTrees, err := rt.FetchClientIpsByGuid(ctx); err != nil {
|
||||
logger.Debug("node traffic sync: fetch client ip attribution from", n.Name, "failed:", err)
|
||||
} else if len(guidTrees) > 0 {
|
||||
if err := j.inboundService.MergeClientIpsByGuid(guidTrees); err != nil {
|
||||
logger.Warning("node traffic sync: merge client ip attribution from", n.Name, "failed:", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -617,3 +617,21 @@ func (r *Remote) PushAllClientIps(ctx context.Context, ips []model.InboundClient
|
||||
_, err := r.do(ctx, http.MethodPost, "panel/api/server/clientIps", ips)
|
||||
return err
|
||||
}
|
||||
|
||||
// FetchClientIpsByGuid pulls the node's per-node IP attribution subtree
|
||||
// (guid -> email -> observed IPs). Unlike FetchAllClientIps (the flat union the
|
||||
// master also pushes back), this preserves which physical node each IP is on.
|
||||
// Returns an empty map for older nodes that lack the endpoint.
|
||||
func (r *Remote) FetchClientIpsByGuid(ctx context.Context) (map[string]map[string][]model.ClientIpEntry, error) {
|
||||
env, err := r.do(ctx, http.MethodPost, "panel/api/clients/clientIpsByGuid", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]map[string][]model.ClientIpEntry{}
|
||||
if len(env.Obj) > 0 {
|
||||
if err := json.Unmarshal(env.Obj, &out); err != nil {
|
||||
return nil, fmt.Errorf("decode client ips by guid: %w", err)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
297
internal/web/service/email/email.go
Normal file
297
internal/web/service/email/email.go
Normal file
@ -0,0 +1,297 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
// EmailService sends email notifications via SMTP.
|
||||
type EmailService struct {
|
||||
settingService service.SettingService
|
||||
}
|
||||
|
||||
// SMTPTestResult holds the result of an SMTP connection test.
|
||||
type SMTPTestResult struct {
|
||||
Success bool `json:"success"`
|
||||
Stage string `json:"stage"` // "connect" | "auth" | "send"
|
||||
Message string `json:"message"` // classified error message
|
||||
}
|
||||
|
||||
// NewEmailService creates a new EmailService.
|
||||
func NewEmailService(settingService service.SettingService) *EmailService {
|
||||
return &EmailService{settingService: settingService}
|
||||
}
|
||||
|
||||
// Send sends an HTML email to all configured recipients.
|
||||
func (s *EmailService) Send(subject, body string) error {
|
||||
host, err := s.settingService.GetSmtpHost()
|
||||
if err != nil || host == "" {
|
||||
return fmt.Errorf("smtp host not configured")
|
||||
}
|
||||
port, err := s.settingService.GetSmtpPort()
|
||||
if err != nil || port <= 0 {
|
||||
port = 587
|
||||
}
|
||||
username, _ := s.settingService.GetSmtpUsername()
|
||||
password, _ := s.settingService.GetSmtpPassword()
|
||||
toStr, _ := s.settingService.GetSmtpTo()
|
||||
encryptionType, _ := s.settingService.GetSmtpEncryptionType()
|
||||
|
||||
from := username
|
||||
if from == "" {
|
||||
return fmt.Errorf("smtp from not configured")
|
||||
}
|
||||
|
||||
recipients := parseRecipients(toStr)
|
||||
if len(recipients) == 0 {
|
||||
return fmt.Errorf("no recipients configured")
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(host, fmt.Sprintf("%d", port))
|
||||
msg := buildMessage(from, recipients, subject, body)
|
||||
|
||||
// Authenticate only when credentials are set. Go's PlainAuth refuses to run
|
||||
// over the unencrypted "none" transport, so an open relay must use nil auth.
|
||||
var auth smtp.Auth
|
||||
if username != "" && password != "" {
|
||||
auth = smtp.PlainAuth("", username, password, host)
|
||||
}
|
||||
|
||||
// Wrap in a channel with timeout to prevent indefinite blocking
|
||||
type result struct{ err error }
|
||||
ch := make(chan result, 1)
|
||||
go func() {
|
||||
switch encryptionType {
|
||||
case "tls":
|
||||
ch <- result{s.sendWithTLS(addr, auth, from, recipients, msg, host)}
|
||||
case "starttls", "none":
|
||||
ch <- result{smtp.SendMail(addr, auth, from, recipients, msg)}
|
||||
default:
|
||||
ch <- result{fmt.Errorf("unknown SMTP encryption type: %s", encryptionType)}
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case r := <-ch:
|
||||
return r.err
|
||||
case <-time.After(30 * time.Second):
|
||||
return fmt.Errorf("smtp connection timed out after 30s")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnection tests SMTP connection stage by stage and sends a test email.
|
||||
func (s *EmailService) TestConnection() SMTPTestResult {
|
||||
host, err := s.settingService.GetSmtpHost()
|
||||
if err != nil || host == "" {
|
||||
return SMTPTestResult{false, "connect", "smtpHostNotConfigured"}
|
||||
}
|
||||
port, err := s.settingService.GetSmtpPort()
|
||||
if err != nil || port <= 0 {
|
||||
port = 587
|
||||
}
|
||||
username, _ := s.settingService.GetSmtpUsername()
|
||||
password, _ := s.settingService.GetSmtpPassword()
|
||||
toStr, _ := s.settingService.GetSmtpTo()
|
||||
encryptionType, _ := s.settingService.GetSmtpEncryptionType()
|
||||
|
||||
from := username
|
||||
|
||||
recipients := parseRecipients(toStr)
|
||||
if len(recipients) == 0 {
|
||||
return SMTPTestResult{false, "send", "smtpNoRecipients"}
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(host, fmt.Sprintf("%d", port))
|
||||
|
||||
// Stage 1: Connect
|
||||
var conn net.Conn
|
||||
dialer := &net.Dialer{Timeout: 5 * time.Second}
|
||||
|
||||
switch encryptionType {
|
||||
case "tls":
|
||||
conn, err = tls.DialWithDialer(dialer, "tcp", addr, &tls.Config{
|
||||
ServerName: host,
|
||||
InsecureSkipVerify: false,
|
||||
})
|
||||
default:
|
||||
conn, err = dialer.Dial("tcp", addr)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return SMTPTestResult{false, "connect", classifySMTPError(err)}
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Stage 2: Handshake + Auth
|
||||
client, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
return SMTPTestResult{false, "auth", classifySMTPError(err)}
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if err = client.Hello("localhost"); err != nil {
|
||||
return SMTPTestResult{false, "auth", classifySMTPError(err)}
|
||||
}
|
||||
|
||||
// STARTTLS upgrade for non-TLS connections
|
||||
if encryptionType == "starttls" {
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
if err = client.StartTLS(&tls.Config{ServerName: host}); err != nil {
|
||||
return SMTPTestResult{false, "auth", classifySMTPError(err)}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if username != "" && password != "" {
|
||||
auth := smtp.PlainAuth("", username, password, host)
|
||||
if err = client.Auth(auth); err != nil {
|
||||
return SMTPTestResult{false, "auth", classifySMTPError(err)}
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 3: Send test email
|
||||
if err = client.Mail(from); err != nil {
|
||||
return SMTPTestResult{false, "send", classifySMTPError(err)}
|
||||
}
|
||||
for _, r := range recipients {
|
||||
if err = client.Rcpt(r); err != nil {
|
||||
return SMTPTestResult{false, "send", classifySMTPError(err)}
|
||||
}
|
||||
}
|
||||
|
||||
msg := buildMessage(from, recipients, "[3x-ui] Test email",
|
||||
`<html><body style="font-family:monospace;font-size:14px">
|
||||
<h2>Test email from 3x-ui</h2>
|
||||
<p>If you received this, SMTP is configured correctly.</p>
|
||||
</body></html>`)
|
||||
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return SMTPTestResult{false, "send", classifySMTPError(err)}
|
||||
}
|
||||
if _, err = w.Write(msg); err != nil {
|
||||
return SMTPTestResult{false, "send", classifySMTPError(err)}
|
||||
}
|
||||
if err = w.Close(); err != nil {
|
||||
return SMTPTestResult{false, "send", classifySMTPError(err)}
|
||||
}
|
||||
|
||||
return SMTPTestResult{true, "send", "smtpTestSuccess"}
|
||||
}
|
||||
|
||||
func (s *EmailService) sendWithTLS(addr string, auth smtp.Auth, from string, to []string, msg []byte, host string) error {
|
||||
// Dial with explicit timeout
|
||||
dialer := &net.Dialer{Timeout: 10 * time.Second}
|
||||
conn, err := tls.DialWithDialer(dialer, "tcp", addr, &tls.Config{
|
||||
ServerName: host,
|
||||
InsecureSkipVerify: false,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if err = client.Hello("localhost"); err != nil {
|
||||
return err
|
||||
}
|
||||
if auth != nil {
|
||||
if err = client.Auth(auth); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err = client.Mail(from); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range to {
|
||||
if err = client.Rcpt(r); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = w.Write(msg); err != nil {
|
||||
return err
|
||||
}
|
||||
return w.Close()
|
||||
}
|
||||
|
||||
// SendTest sends a test email and returns any error with detail.
|
||||
func (s *EmailService) SendTest() error {
|
||||
return s.Send(
|
||||
"[3x-ui] Test email",
|
||||
`<html><body style="font-family:monospace;font-size:14px">
|
||||
<h2>Test email from 3x-ui</h2>
|
||||
<p>If you received this, SMTP is configured correctly.</p>
|
||||
</body></html>`,
|
||||
)
|
||||
}
|
||||
|
||||
// classifySMTPError maps raw SMTP errors to human-readable messages.
|
||||
func classifySMTPError(err error) string {
|
||||
msg := err.Error()
|
||||
msgLower := strings.ToLower(msg)
|
||||
|
||||
switch {
|
||||
case strings.Contains(msg, "535") || strings.Contains(msgLower, "authentication"):
|
||||
return "pages.settings.smtpErrorAuth"
|
||||
case strings.Contains(msg, "534") || strings.Contains(msgLower, "starttls"):
|
||||
return "pages.settings.smtpErrorStarttls"
|
||||
case strings.Contains(msg, "465") || strings.Contains(msgLower, "tls"):
|
||||
return "pages.settings.smtpErrorTls"
|
||||
case strings.Contains(msgLower, "connection refused") || strings.Contains(msgLower, "dial"):
|
||||
return "pages.settings.smtpErrorRefused"
|
||||
case strings.Contains(msgLower, "timeout"):
|
||||
return "pages.settings.smtpErrorTimeout"
|
||||
case strings.Contains(msg, "550") || strings.Contains(msgLower, "relay"):
|
||||
return "pages.settings.smtpErrorRelay"
|
||||
case strings.Contains(msgLower, "eof"):
|
||||
return "pages.settings.smtpErrorEof"
|
||||
default:
|
||||
return fmt.Sprintf("pages.settings.smtpErrorUnknown: %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func parseRecipients(toStr string) []string {
|
||||
if toStr == "" {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, s := range strings.Split(toStr, ",") {
|
||||
s = strings.TrimSpace(s)
|
||||
if s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildMessage(from string, to []string, subject, body string) []byte {
|
||||
headers := map[string]string{
|
||||
"From": from,
|
||||
"To": strings.Join(to, ","),
|
||||
"Subject": subject,
|
||||
"MIME-Version": "1.0",
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
}
|
||||
var msg strings.Builder
|
||||
for k, v := range headers {
|
||||
msg.WriteString(fmt.Sprintf("%s: %s\r\n", k, v))
|
||||
}
|
||||
msg.WriteString("\r\n")
|
||||
msg.WriteString(body)
|
||||
return []byte(msg.String())
|
||||
}
|
||||
52
internal/web/service/email/ratelimiter_test.go
Normal file
52
internal/web/service/email/ratelimiter_test.go
Normal file
@ -0,0 +1,52 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
|
||||
)
|
||||
|
||||
func TestRateLimiterAllow(t *testing.T) {
|
||||
rl := eventbus.NewRateLimiter(time.Minute)
|
||||
|
||||
if !rl.Allow(eventbus.EventOutboundDown, "proxy-1") {
|
||||
t.Error("first call should be allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiterCooldown(t *testing.T) {
|
||||
rl := eventbus.NewRateLimiter(100 * time.Millisecond)
|
||||
|
||||
rl.Allow(eventbus.EventOutboundDown, "proxy-1")
|
||||
|
||||
if rl.Allow(eventbus.EventOutboundDown, "proxy-1") {
|
||||
t.Error("should be blocked during cooldown")
|
||||
}
|
||||
|
||||
time.Sleep(110 * time.Millisecond)
|
||||
|
||||
if !rl.Allow(eventbus.EventOutboundDown, "proxy-1") {
|
||||
t.Error("should be allowed after cooldown")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiterPerType(t *testing.T) {
|
||||
rl := eventbus.NewRateLimiter(time.Minute)
|
||||
|
||||
rl.Allow(eventbus.EventOutboundDown, "proxy-1")
|
||||
|
||||
if !rl.Allow(eventbus.EventOutboundUp, "proxy-1") {
|
||||
t.Error("different event types should be independent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiterPerSource(t *testing.T) {
|
||||
rl := eventbus.NewRateLimiter(time.Minute)
|
||||
|
||||
rl.Allow(eventbus.EventOutboundDown, "proxy-1")
|
||||
|
||||
if !rl.Allow(eventbus.EventOutboundDown, "proxy-2") {
|
||||
t.Error("different sources should be independent")
|
||||
}
|
||||
}
|
||||
182
internal/web/service/email/subscriber.go
Normal file
182
internal/web/service/email/subscriber.go
Normal file
@ -0,0 +1,182 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/locale"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
// Subscriber handles event bus messages and sends email notifications.
|
||||
type Subscriber struct {
|
||||
settingService service.SettingService
|
||||
emailService *EmailService
|
||||
limiter *eventbus.RateLimiter
|
||||
}
|
||||
|
||||
// NewSubscriber creates a new email event subscriber.
|
||||
func NewSubscriber(settingService service.SettingService, emailService *EmailService) *Subscriber {
|
||||
return &Subscriber{
|
||||
settingService: settingService,
|
||||
emailService: emailService,
|
||||
limiter: eventbus.NewRateLimiter(1 * time.Minute),
|
||||
}
|
||||
}
|
||||
|
||||
// HandleEvent is the eventbus subscriber callback.
|
||||
func (s *Subscriber) HandleEvent(e eventbus.Event) {
|
||||
if !s.isEventEnabled(e.Type) {
|
||||
return
|
||||
}
|
||||
if e.Type != eventbus.EventLoginAttempt {
|
||||
if !s.limiter.Allow(e.Type, e.Source) {
|
||||
return
|
||||
}
|
||||
}
|
||||
subject, body := s.formatMessage(e)
|
||||
if subject == "" {
|
||||
return
|
||||
}
|
||||
if err := s.emailService.Send(subject, body); err != nil {
|
||||
logger.Warning("email subscriber: send failed:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Subscriber) isEventEnabled(t eventbus.EventType) bool {
|
||||
events, err := s.settingService.GetSmtpEnabledEvents()
|
||||
if err != nil || events == "" {
|
||||
return false
|
||||
}
|
||||
for _, e := range strings.Split(events, ",") {
|
||||
if strings.TrimSpace(e) == string(t) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func i18n(key string, params ...string) string {
|
||||
return locale.I18n(locale.Bot, key, params...)
|
||||
}
|
||||
|
||||
func (s *Subscriber) formatMessage(e eventbus.Event) (subject, body string) {
|
||||
h, _ := hostname()
|
||||
host := h
|
||||
ts := e.Timestamp.Format("2006-01-02 15:04:05")
|
||||
|
||||
wrap := func(title, content string) string {
|
||||
// Strip newlines from title to prevent broken HTML
|
||||
title = strings.ReplaceAll(title, "\r\n", "")
|
||||
title = strings.ReplaceAll(title, "\n", "")
|
||||
return fmt.Sprintf(`<html><body style="font-family:monospace;font-size:14px;color:#333">
|
||||
<h2 style="color:#555;border-bottom:1px solid #ddd;padding-bottom:8px">📡 %s %s</h2>
|
||||
%s
|
||||
<p style="color:#999;font-size:12px;margin-top:20px">%s</p>
|
||||
</body></html>`, host, title, content, i18n("tgbot.messages.time", "Time=="+ts))
|
||||
}
|
||||
|
||||
kv := func(key, val string) string {
|
||||
return fmt.Sprintf("<p><b>%s:</b> %s</p>", key, val)
|
||||
}
|
||||
|
||||
switch e.Type {
|
||||
case eventbus.EventOutboundDown:
|
||||
subject = host + " " + i18n("tgbot.messages.eventOutboundDown", "Tag=="+e.Source)
|
||||
content := kv(i18n("email.labelStatus"), `<span style="color:red">`+i18n("email.statusDown")+`</span>`)
|
||||
content += kv(i18n("email.labelOutbound"), e.Source)
|
||||
if data, ok := e.Data.(*eventbus.OutboundHealthData); ok {
|
||||
if data.Error != "" {
|
||||
content += kv(i18n("email.labelError"), data.Error)
|
||||
}
|
||||
if data.Delay > 0 {
|
||||
content += kv(i18n("email.labelDelay"), fmt.Sprintf("%dms", data.Delay))
|
||||
}
|
||||
}
|
||||
body = wrap(i18n("tgbot.messages.eventOutboundDown", "Tag=="+e.Source), content)
|
||||
|
||||
case eventbus.EventOutboundUp:
|
||||
subject = host + " " + i18n("tgbot.messages.eventOutboundUp", "Tag=="+e.Source)
|
||||
content := kv(i18n("email.labelStatus"), `<span style="color:green">`+i18n("email.statusUp")+`</span>`)
|
||||
content += kv(i18n("email.labelOutbound"), e.Source)
|
||||
if data, ok := e.Data.(*eventbus.OutboundHealthData); ok && data.Delay > 0 {
|
||||
content += kv(i18n("email.labelDelay"), fmt.Sprintf("%dms", data.Delay))
|
||||
}
|
||||
body = wrap(i18n("tgbot.messages.eventOutboundUp", "Tag=="+e.Source), content)
|
||||
|
||||
case eventbus.EventXrayCrash:
|
||||
subject = host + " " + i18n("tgbot.messages.eventXrayCrash")
|
||||
content := kv(i18n("email.labelStatus"), `<span style="color:red">`+i18n("email.statusCrashed")+`</span>`)
|
||||
if e.Data != nil {
|
||||
content += kv(i18n("email.labelError"), fmt.Sprint(e.Data))
|
||||
}
|
||||
body = wrap(i18n("tgbot.messages.eventXrayCrash"), content)
|
||||
|
||||
case eventbus.EventNodeDown:
|
||||
subject = host + " " + i18n("tgbot.messages.eventNodeDown", "Name=="+e.Source)
|
||||
content := kv(i18n("email.labelStatus"), `<span style="color:red">`+i18n("email.statusDown")+`</span>`)
|
||||
content += kv(i18n("email.labelNode"), e.Source)
|
||||
if data, ok := e.Data.(*eventbus.NodeHealthData); ok && data.XrayError != "" {
|
||||
content += kv(i18n("email.labelError"), data.XrayError)
|
||||
}
|
||||
body = wrap(i18n("tgbot.messages.eventNodeDown", "Name=="+e.Source), content)
|
||||
|
||||
case eventbus.EventNodeUp:
|
||||
subject = host + " " + i18n("tgbot.messages.eventNodeUp", "Name=="+e.Source)
|
||||
content := kv(i18n("email.labelStatus"), `<span style="color:green">`+i18n("email.statusUp")+`</span>`)
|
||||
content += kv(i18n("email.labelNode"), e.Source)
|
||||
if data, ok := e.Data.(*eventbus.NodeHealthData); ok && data.LatencyMs > 0 {
|
||||
content += kv(i18n("email.labelDelay"), fmt.Sprintf("%dms", data.LatencyMs))
|
||||
}
|
||||
body = wrap(i18n("tgbot.messages.eventNodeUp", "Name=="+e.Source), content)
|
||||
|
||||
case eventbus.EventCPUHigh:
|
||||
if data, ok := e.Data.(*eventbus.SystemMetricData); ok {
|
||||
smtpCpu, err := s.settingService.GetSmtpCpu()
|
||||
if err != nil || smtpCpu <= 0 || data.Percent <= float64(smtpCpu) {
|
||||
return
|
||||
}
|
||||
subject = host + " " + i18n("tgbot.messages.cpuThreshold",
|
||||
"Percent=="+strconv.FormatFloat(data.Percent, 'f', 2, 64),
|
||||
"Threshold=="+fmt.Sprintf("%d", smtpCpu))
|
||||
content := kv(i18n("email.labelStatus"), `<span style="color:orange">`+i18n("email.statusHigh")+`</span>`)
|
||||
body = wrap(subject, content)
|
||||
}
|
||||
|
||||
case eventbus.EventLoginAttempt:
|
||||
if data, ok := e.Data.(*eventbus.LoginEventData); ok {
|
||||
if data.Status == "success" {
|
||||
subject = host + " " + i18n("tgbot.messages.loginSuccess")
|
||||
content := kv(i18n("email.labelStatus"), `<span style="color:green">`+i18n("email.statusSuccess")+`</span>`)
|
||||
content += kv(i18n("email.labelUsername"), data.Username)
|
||||
content += kv(i18n("email.labelIP"), data.IP)
|
||||
body = wrap(i18n("tgbot.messages.loginSuccess"), content)
|
||||
} else {
|
||||
subject = host + " " + i18n("tgbot.messages.loginFailed")
|
||||
content := kv(i18n("email.labelStatus"), `<span style="color:red">`+i18n("email.statusFailed")+`</span>`)
|
||||
if data.Reason != "" {
|
||||
content += kv(i18n("email.labelReason"), data.Reason)
|
||||
}
|
||||
content += kv(i18n("email.labelUsername"), data.Username)
|
||||
content += kv(i18n("email.labelIP"), data.IP)
|
||||
body = wrap(i18n("tgbot.messages.loginFailed"), content)
|
||||
}
|
||||
} else {
|
||||
subject = host + " " + i18n("tgbot.messages.loginFailed")
|
||||
content := kv(i18n("email.labelStatus"), `<span style="color:red">`+i18n("email.statusFailed")+`</span>`)
|
||||
content += kv(i18n("email.labelSource"), e.Source)
|
||||
body = wrap(i18n("tgbot.messages.loginFailed"), content)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func hostname() (string, error) {
|
||||
return os.Hostname()
|
||||
}
|
||||
269
internal/web/service/inbound_node_ips.go
Normal file
269
internal/web/service/inbound_node_ips.go
Normal file
@ -0,0 +1,269 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// node_client_ips.go implements per-node client-IP attribution. The flat
|
||||
// inbound_client_ips table is a cluster-wide union (used for IP-limit counting
|
||||
// and pushed back to every node), so it cannot tell which node a given IP is
|
||||
// on. NodeClientIp keeps that attribution: each panel records its own Xray
|
||||
// observations under its panelGuid, and the master merges every node's
|
||||
// guid-keyed report — never mixing in IPs a parent pushed down.
|
||||
|
||||
// mergeModelClientIpEntries unions old and incoming observations, drops anything
|
||||
// older than cutoff, keeps the newest timestamp per IP, and sorts newest-first.
|
||||
// It mirrors mergeClientIpEntries but operates on the exported wire type.
|
||||
func mergeModelClientIpEntries(old, incoming []model.ClientIpEntry, cutoff int64) []model.ClientIpEntry {
|
||||
ipMap := make(map[string]int64, len(old)+len(incoming))
|
||||
for _, e := range old {
|
||||
if e.IP == "" || e.Timestamp < cutoff {
|
||||
continue
|
||||
}
|
||||
ipMap[e.IP] = e.Timestamp
|
||||
}
|
||||
for _, e := range incoming {
|
||||
if e.IP == "" || e.Timestamp < cutoff {
|
||||
continue
|
||||
}
|
||||
if cur, ok := ipMap[e.IP]; !ok || e.Timestamp > cur {
|
||||
ipMap[e.IP] = e.Timestamp
|
||||
}
|
||||
}
|
||||
out := make([]model.ClientIpEntry, 0, len(ipMap))
|
||||
for ip, ts := range ipMap {
|
||||
out = append(out, model.ClientIpEntry{IP: ip, Timestamp: ts})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Timestamp > out[j].Timestamp })
|
||||
return out
|
||||
}
|
||||
|
||||
// upsertNodeClientIps folds a guid's per-email observations into NodeClientIp,
|
||||
// merging with whatever is already stored for that (guid, email) and dropping
|
||||
// stale entries. Empty merged results delete the row so the table stays bounded.
|
||||
func upsertNodeClientIps(guid string, perEmail map[string][]model.ClientIpEntry) error {
|
||||
if guid == "" || len(perEmail) == 0 {
|
||||
return nil
|
||||
}
|
||||
db := database.GetDB()
|
||||
cutoff := time.Now().Unix() - clientIpStaleAfterSeconds
|
||||
|
||||
var existing []model.NodeClientIp
|
||||
if err := db.Where("node_guid = ?", guid).Find(&existing).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
existingByEmail := make(map[string]*model.NodeClientIp, len(existing))
|
||||
for i := range existing {
|
||||
existingByEmail[existing[i].Email] = &existing[i]
|
||||
}
|
||||
|
||||
tx := db.Begin()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
for email, incoming := range perEmail {
|
||||
if email == "" {
|
||||
continue
|
||||
}
|
||||
var old []model.ClientIpEntry
|
||||
if cur, ok := existingByEmail[email]; ok && cur.Ips != "" {
|
||||
_ = json.Unmarshal([]byte(cur.Ips), &old)
|
||||
}
|
||||
merged := mergeModelClientIpEntries(old, incoming, cutoff)
|
||||
if len(merged) == 0 {
|
||||
// Nothing fresh: drop any stale row so attribution doesn't linger.
|
||||
if _, ok := existingByEmail[email]; ok {
|
||||
if err := tx.Where("node_guid = ? AND email = ?", guid, email).
|
||||
Delete(&model.NodeClientIp{}).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
b, _ := json.Marshal(merged)
|
||||
row := model.NodeClientIp{NodeGuid: guid, Email: email, Ips: string(b)}
|
||||
if err := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "node_guid"}, {Name: "email"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"ips"}),
|
||||
}).Create(&row).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit().Error
|
||||
}
|
||||
|
||||
// RecordLocalClientIps stores this panel's own Xray observations under its
|
||||
// panelGuid. Called by check_client_ip_job each scan with the live per-email IPs
|
||||
// the local core reported.
|
||||
func (s *InboundService) RecordLocalClientIps(panelGuid string, observed map[string][]model.ClientIpEntry) error {
|
||||
return upsertNodeClientIps(panelGuid, observed)
|
||||
}
|
||||
|
||||
// MergeClientIpsByGuid folds a node's guid-keyed attribution report (its own
|
||||
// panelGuid subtree plus any descendants) into the local table, preserving which
|
||||
// physical node each IP is on across a chain.
|
||||
func (s *InboundService) MergeClientIpsByGuid(trees map[string]map[string][]model.ClientIpEntry) error {
|
||||
for guid, perEmail := range trees {
|
||||
if err := upsertNodeClientIps(guid, perEmail); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetClientIpsByGuid returns this panel's full attribution subtree (guid -> email
|
||||
// -> fresh IPs), dropping stale entries. It is what the clientIpsByGuid endpoint
|
||||
// serves to a parent panel.
|
||||
func (s *InboundService) GetClientIpsByGuid() (map[string]map[string][]model.ClientIpEntry, error) {
|
||||
db := database.GetDB()
|
||||
var rows []model.NodeClientIp
|
||||
if err := db.Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cutoff := time.Now().Unix() - clientIpStaleAfterSeconds
|
||||
out := make(map[string]map[string][]model.ClientIpEntry)
|
||||
for _, row := range rows {
|
||||
if row.NodeGuid == "" || row.Email == "" || row.Ips == "" {
|
||||
continue
|
||||
}
|
||||
var entries []model.ClientIpEntry
|
||||
if err := json.Unmarshal([]byte(row.Ips), &entries); err != nil {
|
||||
continue
|
||||
}
|
||||
fresh := mergeModelClientIpEntries(nil, entries, cutoff)
|
||||
if len(fresh) == 0 {
|
||||
continue
|
||||
}
|
||||
if out[row.NodeGuid] == nil {
|
||||
out[row.NodeGuid] = make(map[string][]model.ClientIpEntry)
|
||||
}
|
||||
out[row.NodeGuid][row.Email] = fresh
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetClientIpNodeAttribution returns, for one client email, a map of IP -> the
|
||||
// guid that most recently observed it (within the stale window). Used to label
|
||||
// each IP in the panel with the node it is connecting to.
|
||||
func (s *InboundService) GetClientIpNodeAttribution(email string) (map[string]string, error) {
|
||||
db := database.GetDB()
|
||||
var rows []model.NodeClientIp
|
||||
if err := db.Where("email = ?", email).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cutoff := time.Now().Unix() - clientIpStaleAfterSeconds
|
||||
ipGuid := make(map[string]string)
|
||||
ipTs := make(map[string]int64)
|
||||
for _, row := range rows {
|
||||
if row.NodeGuid == "" || row.Ips == "" {
|
||||
continue
|
||||
}
|
||||
var entries []model.ClientIpEntry
|
||||
if err := json.Unmarshal([]byte(row.Ips), &entries); err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IP == "" || e.Timestamp < cutoff {
|
||||
continue
|
||||
}
|
||||
if cur, ok := ipTs[e.IP]; !ok || e.Timestamp > cur {
|
||||
ipTs[e.IP] = e.Timestamp
|
||||
ipGuid[e.IP] = row.NodeGuid
|
||||
}
|
||||
}
|
||||
}
|
||||
return ipGuid, nil
|
||||
}
|
||||
|
||||
// ClientIpInfo is one IP shown in the panel's per-client IP log, labelled with
|
||||
// the node it is connecting through ("" = this local panel).
|
||||
type ClientIpInfo struct {
|
||||
IP string `json:"ip"`
|
||||
Time string `json:"time"`
|
||||
Node string `json:"node"`
|
||||
}
|
||||
|
||||
// GetClientIpsWithNodes returns a client's recorded IPs (from the flat
|
||||
// inbound_client_ips display set) annotated with the node each IP is on, using
|
||||
// the per-node attribution table. Local IPs (and any IP without attribution)
|
||||
// carry an empty Node.
|
||||
func (s *InboundService) GetClientIpsWithNodes(email string) ([]ClientIpInfo, error) {
|
||||
raw, err := s.GetInboundClientIps(email)
|
||||
if err != nil || raw == "" {
|
||||
// Record-not-found (or empty) is "no IPs", not an error for the UI.
|
||||
return []ClientIpInfo{}, nil
|
||||
}
|
||||
|
||||
var entries []model.ClientIpEntry
|
||||
if jerr := json.Unmarshal([]byte(raw), &entries); jerr != nil || len(entries) == 0 {
|
||||
// Legacy shape: a plain JSON array of IP strings.
|
||||
var oldIps []string
|
||||
if json.Unmarshal([]byte(raw), &oldIps) == nil {
|
||||
entries = entries[:0]
|
||||
for _, ip := range oldIps {
|
||||
entries = append(entries, model.ClientIpEntry{IP: ip})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return []ClientIpInfo{}, nil
|
||||
}
|
||||
|
||||
attr, _ := s.GetClientIpNodeAttribution(email)
|
||||
guidName := s.nodeGuidNameMap()
|
||||
localGuid, _ := (&SettingService{}).GetPanelGuid()
|
||||
|
||||
out := make([]ClientIpInfo, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IP == "" {
|
||||
continue
|
||||
}
|
||||
info := ClientIpInfo{IP: e.IP}
|
||||
if e.Timestamp > 0 {
|
||||
info.Time = time.Unix(e.Timestamp, 0).Local().Format("2006-01-02 15:04:05")
|
||||
}
|
||||
if guid, ok := attr[e.IP]; ok && guid != "" && guid != localGuid {
|
||||
info.Node = guidName[guid]
|
||||
}
|
||||
out = append(out, info)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// nodeGuidNameMap maps each known node's stable guid to its display name.
|
||||
func (s *InboundService) nodeGuidNameMap() map[string]string {
|
||||
db := database.GetDB()
|
||||
var nodes []model.Node
|
||||
if err := db.Model(&model.Node{}).Find(&nodes).Error; err != nil {
|
||||
return map[string]string{}
|
||||
}
|
||||
m := make(map[string]string, len(nodes))
|
||||
for _, n := range nodes {
|
||||
if n.Guid != "" {
|
||||
m[n.Guid] = n.Name
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// DeleteNodeClientIpsByGuid removes all attribution rows for a guid (e.g. when a
|
||||
// node is deleted) so its IPs stop being reported and counted.
|
||||
func (s *InboundService) DeleteNodeClientIpsByGuid(guid string) error {
|
||||
if guid == "" {
|
||||
return nil
|
||||
}
|
||||
db := database.GetDB()
|
||||
return db.Where("node_guid = ?", guid).Delete(&model.NodeClientIp{}).Error
|
||||
}
|
||||
168
internal/web/service/inbound_node_ips_test.go
Normal file
168
internal/web/service/inbound_node_ips_test.go
Normal file
@ -0,0 +1,168 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func TestRecordLocalClientIps_RoundTripByGuid(t *testing.T) {
|
||||
setupClientIpTestDB(t)
|
||||
now := time.Now().Unix()
|
||||
svc := &InboundService{}
|
||||
|
||||
if err := svc.RecordLocalClientIps("guid-A", map[string][]model.ClientIpEntry{
|
||||
"u@x": {{IP: "1.1.1.1", Timestamp: now}, {IP: "2.2.2.2", Timestamp: now - 10}},
|
||||
}); err != nil {
|
||||
t.Fatalf("record: %v", err)
|
||||
}
|
||||
|
||||
trees, err := svc.GetClientIpsByGuid()
|
||||
if err != nil {
|
||||
t.Fatalf("byGuid: %v", err)
|
||||
}
|
||||
got := trees["guid-A"]["u@x"]
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("want 2 entries, got %v", got)
|
||||
}
|
||||
if got[0].IP != "1.1.1.1" { // newest-first ordering
|
||||
t.Fatalf("want newest first, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordLocalClientIps_MergesAndDropsStale(t *testing.T) {
|
||||
setupClientIpTestDB(t)
|
||||
now := time.Now().Unix()
|
||||
svc := &InboundService{}
|
||||
|
||||
if err := svc.RecordLocalClientIps("g", map[string][]model.ClientIpEntry{
|
||||
"u@x": {{IP: "keep", Timestamp: now - 60}},
|
||||
}); err != nil {
|
||||
t.Fatalf("record 1: %v", err)
|
||||
}
|
||||
// Second scan refreshes keep, adds a stale entry (must be dropped) and a fresh one.
|
||||
if err := svc.RecordLocalClientIps("g", map[string][]model.ClientIpEntry{
|
||||
"u@x": {{IP: "keep", Timestamp: now}, {IP: "stale", Timestamp: now - 4000}, {IP: "new", Timestamp: now - 5}},
|
||||
}); err != nil {
|
||||
t.Fatalf("record 2: %v", err)
|
||||
}
|
||||
|
||||
trees, _ := svc.GetClientIpsByGuid()
|
||||
got := map[string]int64{}
|
||||
for _, e := range trees["g"]["u@x"] {
|
||||
got[e.IP] = e.Timestamp
|
||||
}
|
||||
if got["keep"] != now {
|
||||
t.Fatalf("keep should refresh to now: %v", got)
|
||||
}
|
||||
if _, ok := got["stale"]; ok {
|
||||
t.Fatalf("stale entry should be dropped: %v", got)
|
||||
}
|
||||
if got["new"] != now-5 {
|
||||
t.Fatalf("new missing: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertNodeClientIps_EmptyMergeDeletesRow(t *testing.T) {
|
||||
setupClientIpTestDB(t)
|
||||
now := time.Now().Unix()
|
||||
db := database.GetDB()
|
||||
svc := &InboundService{}
|
||||
|
||||
// Seed an already-stale row, then record another all-stale observation: the
|
||||
// merge yields nothing fresh, so the row must be removed (not left lingering).
|
||||
staleIps, _ := json.Marshal([]model.ClientIpEntry{{IP: "old", Timestamp: now - 999999}})
|
||||
if err := db.Create(&model.NodeClientIp{NodeGuid: "g", Email: "u@x", Ips: string(staleIps)}).Error; err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if err := svc.RecordLocalClientIps("g", map[string][]model.ClientIpEntry{
|
||||
"u@x": {{IP: "old2", Timestamp: now - 999999}},
|
||||
}); err != nil {
|
||||
t.Fatalf("record: %v", err)
|
||||
}
|
||||
|
||||
var count int64
|
||||
database.GetDB().Model(&model.NodeClientIp{}).
|
||||
Where("node_guid = ? AND email = ?", "g", "u@x").Count(&count)
|
||||
if count != 0 {
|
||||
t.Fatalf("row should be deleted when merge is empty, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetClientIpNodeAttribution_NewestGuidWins(t *testing.T) {
|
||||
setupClientIpTestDB(t)
|
||||
now := time.Now().Unix()
|
||||
svc := &InboundService{}
|
||||
|
||||
// Same IP observed on two panels; the most recent observation attributes it.
|
||||
if err := svc.RecordLocalClientIps("gA", map[string][]model.ClientIpEntry{
|
||||
"u@x": {{IP: "9.9.9.9", Timestamp: now - 100}},
|
||||
}); err != nil {
|
||||
t.Fatalf("record gA: %v", err)
|
||||
}
|
||||
if err := svc.MergeClientIpsByGuid(map[string]map[string][]model.ClientIpEntry{
|
||||
"gB": {"u@x": {{IP: "9.9.9.9", Timestamp: now}}},
|
||||
}); err != nil {
|
||||
t.Fatalf("merge gB: %v", err)
|
||||
}
|
||||
|
||||
attr, err := svc.GetClientIpNodeAttribution("u@x")
|
||||
if err != nil {
|
||||
t.Fatalf("attribution: %v", err)
|
||||
}
|
||||
if attr["9.9.9.9"] != "gB" {
|
||||
t.Fatalf("newest guid should win, got %q", attr["9.9.9.9"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetClientIpsWithNodes_LabelsNodes(t *testing.T) {
|
||||
setupClientIpTestDB(t)
|
||||
now := time.Now().Unix()
|
||||
db := database.GetDB()
|
||||
svc := &InboundService{}
|
||||
|
||||
panelGuid, err := (&SettingService{}).GetPanelGuid()
|
||||
if err != nil || panelGuid == "" {
|
||||
t.Fatalf("panel guid: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Create(&model.Node{Name: "edge-1", Guid: "node-guid", Address: "x", Port: 2053, ApiToken: "t"}).Error; err != nil {
|
||||
t.Fatalf("seed node: %v", err)
|
||||
}
|
||||
|
||||
// Flat display set (what the IP-log lists) holds both IPs.
|
||||
flat, _ := json.Marshal([]model.ClientIpEntry{{IP: "1.1.1.1", Timestamp: now}, {IP: "2.2.2.2", Timestamp: now}})
|
||||
if err := db.Create(&model.InboundClientIps{ClientEmail: "u@x", Ips: string(flat)}).Error; err != nil {
|
||||
t.Fatalf("seed flat ips: %v", err)
|
||||
}
|
||||
|
||||
// Attribution: 1.1.1.1 seen locally, 2.2.2.2 seen on the node.
|
||||
if err := svc.RecordLocalClientIps(panelGuid, map[string][]model.ClientIpEntry{
|
||||
"u@x": {{IP: "1.1.1.1", Timestamp: now}},
|
||||
}); err != nil {
|
||||
t.Fatalf("record local: %v", err)
|
||||
}
|
||||
if err := svc.MergeClientIpsByGuid(map[string]map[string][]model.ClientIpEntry{
|
||||
"node-guid": {"u@x": {{IP: "2.2.2.2", Timestamp: now}}},
|
||||
}); err != nil {
|
||||
t.Fatalf("merge node: %v", err)
|
||||
}
|
||||
|
||||
infos, err := svc.GetClientIpsWithNodes("u@x")
|
||||
if err != nil {
|
||||
t.Fatalf("getIpsWithNodes: %v", err)
|
||||
}
|
||||
byIP := map[string]string{}
|
||||
for _, in := range infos {
|
||||
byIP[in.IP] = in.Node
|
||||
}
|
||||
if byIP["1.1.1.1"] != "" {
|
||||
t.Fatalf("local IP should have empty node, got %q", byIP["1.1.1.1"])
|
||||
}
|
||||
if byIP["2.2.2.2"] != "edge-1" {
|
||||
t.Fatalf("node IP should be labelled edge-1, got %q", byIP["2.2.2.2"])
|
||||
}
|
||||
}
|
||||
@ -357,9 +357,27 @@ func (s *NodeService) GetRemoteInboundOptions(ctx context.Context, n *model.Node
|
||||
if err := s.normalize(n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return runtime.NewRemote(n, nil).ListInboundOptions(ctx)
|
||||
if n.OutboundTag == "" {
|
||||
return runtime.NewRemote(n, nil).ListInboundOptions(ctx)
|
||||
}
|
||||
// Mirror ProbeWithOutbound: a node being added/edited has no persistent
|
||||
// egress bridge yet, so route the list call through a temporary one or the
|
||||
// remote panel stays unreachable and the request times out.
|
||||
var options []runtime.RemoteInboundOption
|
||||
var err error
|
||||
s.withOutboundBridge(n.Id, n.OutboundTag, func(proxyURL string) {
|
||||
options, err = runtime.NewRemote(n, staticEgressResolver(proxyURL)).ListInboundOptions(ctx)
|
||||
})
|
||||
return options, err
|
||||
}
|
||||
|
||||
// staticEgressResolver hands a fixed proxy URL to runtime.NewRemote. An empty
|
||||
// string yields a direct connection, so it doubles as the graceful fallback
|
||||
// when a temporary bridge can't be built.
|
||||
type staticEgressResolver string
|
||||
|
||||
func (r staticEgressResolver) NodeEgressProxyURL(int) string { return string(r) }
|
||||
|
||||
// EnsureInboundTagAllowed adds a panel-managed inbound's tag to the node's
|
||||
// selection when the node syncs in "selected" mode. Without it, the next
|
||||
// traffic sync would filter the tag out of the snapshot and the orphan sweep
|
||||
@ -415,12 +433,24 @@ func FilterNodeSnapshot(n *model.Node, snap *runtime.TrafficSnapshot) {
|
||||
|
||||
func (s *NodeService) Delete(id int) error {
|
||||
db := database.GetDB()
|
||||
// Capture the node's guid before deleting the row so we can drop its per-node
|
||||
// IP attribution (NodeClientIp is keyed by guid, not node id).
|
||||
var guid string
|
||||
var n model.Node
|
||||
if err := db.Select("guid").Where("id = ?", id).First(&n).Error; err == nil {
|
||||
guid = n.Guid
|
||||
}
|
||||
if err := db.Where("id = ?", id).Delete(model.Node{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Where("node_id = ?", id).Delete(&model.NodeClientTraffic{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if guid != "" {
|
||||
if err := db.Where("node_guid = ?", guid).Delete(&model.NodeClientIp{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if mgr := runtime.GetManager(); mgr != nil {
|
||||
mgr.InvalidateNode(id)
|
||||
}
|
||||
@ -611,23 +641,46 @@ func (s *NodeService) ProbeWithOutbound(ctx context.Context, n *model.Node, outb
|
||||
if outboundTag == "" {
|
||||
return s.Probe(ctx, n)
|
||||
}
|
||||
var patch HeartbeatPatch
|
||||
var err error
|
||||
s.withOutboundBridge(n.Id, outboundTag, func(proxyURL string) {
|
||||
if proxyURL == "" {
|
||||
patch, err = s.Probe(ctx, n)
|
||||
return
|
||||
}
|
||||
patch, err = s.probe(ctx, n, proxyURL)
|
||||
})
|
||||
return patch, err
|
||||
}
|
||||
|
||||
// withOutboundBridge stands up a temporary loopback SOCKS5 inbound in the
|
||||
// running Xray, routes it through outboundTag, and runs fn with the bridge's
|
||||
// proxy URL before tearing it down. It is used to reach a node through its
|
||||
// connection outbound before the persistent egress bridge has been injected
|
||||
// into the config (e.g. while the node is still being added or edited). When
|
||||
// Xray isn't running or the bridge can't be built, fn runs with an empty
|
||||
// proxyURL so callers fall back to a direct connection.
|
||||
func (s *NodeService) withOutboundBridge(nodeID int, outboundTag string, fn func(proxyURL string)) {
|
||||
proc := XrayProcess()
|
||||
if proc == nil || !proc.IsRunning() {
|
||||
return s.Probe(ctx, n)
|
||||
fn("")
|
||||
return
|
||||
}
|
||||
apiPort := proc.GetAPIPort()
|
||||
if apiPort <= 0 {
|
||||
return s.Probe(ctx, n)
|
||||
fn("")
|
||||
return
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return s.Probe(ctx, n)
|
||||
fn("")
|
||||
return
|
||||
}
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
listener.Close()
|
||||
|
||||
tag := fmt.Sprintf("node-test-%d-%d", n.Id, time.Now().UnixNano())
|
||||
tag := fmt.Sprintf("node-test-%d-%d", nodeID, time.Now().UnixNano())
|
||||
proxyURL := fmt.Sprintf("socks5://127.0.0.1:%d", port)
|
||||
|
||||
inboundJSON, err := json.Marshal(xray.InboundConfig{
|
||||
@ -638,7 +691,8 @@ func (s *NodeService) ProbeWithOutbound(ctx context.Context, n *model.Node, outb
|
||||
Tag: tag,
|
||||
})
|
||||
if err != nil {
|
||||
return s.Probe(ctx, n)
|
||||
fn("")
|
||||
return
|
||||
}
|
||||
|
||||
cfg := proc.GetConfig()
|
||||
@ -659,31 +713,31 @@ func (s *NodeService) ProbeWithOutbound(ctx context.Context, n *model.Node, outb
|
||||
routing["rules"] = append([]any{rule}, rules...)
|
||||
routingJSON, err := json.Marshal(routing)
|
||||
if err != nil {
|
||||
return s.Probe(ctx, n)
|
||||
fn("")
|
||||
return
|
||||
}
|
||||
originalRoutingJSON := cfg.RouterConfig
|
||||
|
||||
api := xray.XrayAPI{}
|
||||
if err := api.Init(apiPort); err != nil {
|
||||
return s.Probe(ctx, n)
|
||||
fn("")
|
||||
return
|
||||
}
|
||||
defer api.Close()
|
||||
|
||||
if err := api.AddInbound(inboundJSON); err != nil {
|
||||
return s.Probe(ctx, n)
|
||||
fn("")
|
||||
return
|
||||
}
|
||||
removed := false
|
||||
defer func() {
|
||||
if removed {
|
||||
return
|
||||
}
|
||||
if err := api.DelInbound(tag); err != nil {
|
||||
logger.Warning("remove temp node test inbound failed:", err)
|
||||
logger.Warning("remove temp node bridge inbound failed:", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := api.ApplyRoutingConfig(routingJSON); err != nil {
|
||||
return s.Probe(ctx, n)
|
||||
fn("")
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
restore := originalRoutingJSON
|
||||
@ -691,19 +745,11 @@ func (s *NodeService) ProbeWithOutbound(ctx context.Context, n *model.Node, outb
|
||||
restore = []byte("{}")
|
||||
}
|
||||
if err := api.ApplyRoutingConfig(restore); err != nil {
|
||||
logger.Warning("restore routing after node test failed:", err)
|
||||
logger.Warning("restore routing after node bridge failed:", err)
|
||||
}
|
||||
}()
|
||||
|
||||
patch, err := s.probe(ctx, n, proxyURL)
|
||||
removed = true
|
||||
if delErr := api.DelInbound(tag); delErr != nil {
|
||||
logger.Warning("remove temp node test inbound failed:", delErr)
|
||||
}
|
||||
if err != nil {
|
||||
return patch, err
|
||||
}
|
||||
return patch, nil
|
||||
fn(proxyURL)
|
||||
}
|
||||
|
||||
func (s *NodeService) probe(ctx context.Context, n *model.Node, proxyURL string) (HeartbeatPatch, error) {
|
||||
|
||||
@ -53,7 +53,6 @@ var defaultValueMap = map[string]string{
|
||||
"tgBotChatId": "",
|
||||
"tgRunTime": "@daily",
|
||||
"tgBotBackup": "false",
|
||||
"tgBotLoginNotify": "true",
|
||||
"tgCpu": "80",
|
||||
"tgLang": "en-US",
|
||||
"twoFactorEnable": "false",
|
||||
@ -119,6 +118,20 @@ var defaultValueMap = map[string]string{
|
||||
"ldapDefaultTotalGB": "0",
|
||||
"ldapDefaultExpiryDays": "0",
|
||||
"ldapDefaultLimitIP": "0",
|
||||
|
||||
// Event bus — per-subscriber event filtering (empty = all disabled)
|
||||
"tgEnabledEvents": "login.attempt,cpu.high",
|
||||
"smtpEnabledEvents": "login.attempt,cpu.high",
|
||||
"smtpCpu": "80",
|
||||
|
||||
// Email (SMTP) notifications
|
||||
"smtpEnable": "false",
|
||||
"smtpHost": "",
|
||||
"smtpPort": "587",
|
||||
"smtpUsername": "",
|
||||
"smtpPassword": "",
|
||||
"smtpTo": "",
|
||||
"smtpEncryptionType": "starttls", // no, starttls, tls
|
||||
}
|
||||
|
||||
// SettingService provides business logic for application settings management.
|
||||
@ -220,6 +233,7 @@ func (s *SettingService) GetAllSettingView() (*entity.AllSettingView, error) {
|
||||
view.HasLdapPassword = secretConfigured(allSetting.LdapPassword)
|
||||
view.HasWarpSecret = secretConfigured(mustString(s.GetWarp()))
|
||||
view.HasNordSecret = secretConfigured(mustString(s.GetNord()))
|
||||
view.HasSmtpPassword = secretConfigured(allSetting.SmtpPassword)
|
||||
var apiTokenCount int64
|
||||
if err := database.GetDB().Model(model.ApiToken{}).Where("enabled = ?", true).Count(&apiTokenCount).Error; err == nil {
|
||||
view.HasApiToken = apiTokenCount > 0
|
||||
@ -227,6 +241,7 @@ func (s *SettingService) GetAllSettingView() (*entity.AllSettingView, error) {
|
||||
view.TgBotToken = ""
|
||||
view.TwoFactorToken = ""
|
||||
view.LdapPassword = ""
|
||||
view.SmtpPassword = ""
|
||||
return view, nil
|
||||
}
|
||||
|
||||
@ -504,10 +519,6 @@ func (s *SettingService) GetTgBotBackup() (bool, error) {
|
||||
return s.getBool("tgBotBackup")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetTgBotLoginNotify() (bool, error) {
|
||||
return s.getBool("tgBotLoginNotify")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetTgCpu() (int, error) {
|
||||
return s.getInt("tgCpu")
|
||||
}
|
||||
@ -918,6 +929,90 @@ func (s *SettingService) GetLdapDefaultLimitIP() (int, error) {
|
||||
return s.getInt("ldapDefaultLimitIP")
|
||||
}
|
||||
|
||||
// Event bus — per-subscriber event filtering
|
||||
|
||||
func (s *SettingService) GetTgEnabledEvents() (string, error) {
|
||||
return s.getString("tgEnabledEvents")
|
||||
}
|
||||
|
||||
func (s *SettingService) SetTgEnabledEvents(events string) error {
|
||||
return s.setString("tgEnabledEvents", events)
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSmtpEnabledEvents() (string, error) {
|
||||
return s.getString("smtpEnabledEvents")
|
||||
}
|
||||
|
||||
func (s *SettingService) SetSmtpEnabledEvents(events string) error {
|
||||
return s.setString("smtpEnabledEvents", events)
|
||||
}
|
||||
|
||||
// Email (SMTP) settings
|
||||
|
||||
func (s *SettingService) GetSmtpEnable() (bool, error) {
|
||||
return s.getBool("smtpEnable")
|
||||
}
|
||||
|
||||
func (s *SettingService) SetSmtpEnable(value bool) error {
|
||||
return s.setBool("smtpEnable", value)
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSmtpHost() (string, error) {
|
||||
return s.getString("smtpHost")
|
||||
}
|
||||
|
||||
func (s *SettingService) SetSmtpHost(value string) error {
|
||||
return s.setString("smtpHost", value)
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSmtpPort() (int, error) {
|
||||
return s.getInt("smtpPort")
|
||||
}
|
||||
|
||||
func (s *SettingService) SetSmtpPort(value int) error {
|
||||
return s.setInt("smtpPort", value)
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSmtpUsername() (string, error) {
|
||||
return s.getString("smtpUsername")
|
||||
}
|
||||
|
||||
func (s *SettingService) SetSmtpUsername(value string) error {
|
||||
return s.setString("smtpUsername", value)
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSmtpPassword() (string, error) {
|
||||
return s.getString("smtpPassword")
|
||||
}
|
||||
|
||||
func (s *SettingService) SetSmtpPassword(value string) error {
|
||||
return s.setString("smtpPassword", value)
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSmtpTo() (string, error) {
|
||||
return s.getString("smtpTo")
|
||||
}
|
||||
|
||||
func (s *SettingService) SetSmtpTo(value string) error {
|
||||
return s.setString("smtpTo", value)
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSmtpEncryptionType() (string, error) {
|
||||
return s.getString("smtpEncryptionType")
|
||||
}
|
||||
|
||||
func (s *SettingService) SetSmtpEncryptionType(value string) error {
|
||||
return s.setString("smtpEncryptionType", value)
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSmtpCpu() (int, error) {
|
||||
return s.getInt("smtpCpu")
|
||||
}
|
||||
|
||||
func (s *SettingService) SetSmtpCpu(value int) error {
|
||||
return s.setInt("smtpCpu", value)
|
||||
}
|
||||
|
||||
func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
|
||||
if err := s.preserveRedactedSecrets(allSetting); err != nil {
|
||||
return err
|
||||
@ -967,6 +1062,13 @@ func (s *SettingService) preserveRedactedSecrets(allSetting *entity.AllSetting)
|
||||
}
|
||||
allSetting.TwoFactorToken = value
|
||||
}
|
||||
if strings.TrimSpace(allSetting.SmtpPassword) == "" {
|
||||
value, err := s.GetSmtpPassword()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
allSetting.SmtpPassword = value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@ -32,6 +32,9 @@ func TestGetAllSettingViewRedactsSecrets(t *testing.T) {
|
||||
if err := s.saveSetting("ldapPassword", "ldap-secret"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.saveSetting("smtpPassword", "smtp-secret"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.GetDB().Create(&model.ApiToken{Name: "test", Token: "api-secret", Enabled: true}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -40,10 +43,10 @@ func TestGetAllSettingViewRedactsSecrets(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if view.TgBotToken != "" || view.TwoFactorToken != "" || view.LdapPassword != "" {
|
||||
if view.TgBotToken != "" || view.TwoFactorToken != "" || view.LdapPassword != "" || view.SmtpPassword != "" {
|
||||
t.Fatalf("settings view leaked secrets: %#v", view)
|
||||
}
|
||||
if !view.HasTgBotToken || !view.HasTwoFactorToken || !view.HasLdapPassword || !view.HasApiToken {
|
||||
if !view.HasTgBotToken || !view.HasTwoFactorToken || !view.HasLdapPassword || !view.HasApiToken || !view.HasSmtpPassword {
|
||||
t.Fatalf("settings view did not report configured secret flags: %#v", view)
|
||||
}
|
||||
}
|
||||
@ -63,6 +66,9 @@ func TestUpdateAllSettingPreservesRedactedSecrets(t *testing.T) {
|
||||
if err := s.saveSetting("twoFactorToken", "totp-secret"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.saveSetting("smtpPassword", "smtp-secret"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
view, err := s.GetAllSettingView()
|
||||
if err != nil {
|
||||
@ -81,6 +87,9 @@ func TestUpdateAllSettingPreservesRedactedSecrets(t *testing.T) {
|
||||
if got, _ := s.GetTwoFactorToken(); got != "totp-secret" {
|
||||
t.Fatalf("2fa token = %q, want preserved secret", got)
|
||||
}
|
||||
if got, _ := s.GetSmtpPassword(); got != "smtp-secret" {
|
||||
t.Fatalf("smtp password = %q, want preserved secret", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizePublicHTTPURLBlocksPrivateAddressUnlessAllowed(t *testing.T) {
|
||||
|
||||
@ -15,6 +15,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/global"
|
||||
@ -43,6 +44,9 @@ var (
|
||||
hostname string
|
||||
hashStorage *global.HashStorage
|
||||
|
||||
// EventBus is set from web layer to publish login/security events.
|
||||
EventBus *eventbus.Bus
|
||||
|
||||
// Performance improvements
|
||||
messageWorkerPool chan struct{} // Semaphore for limiting concurrent message processing
|
||||
optimizedHTTPClient *http.Client // HTTP client with connection pooling and timeouts
|
||||
|
||||
150
internal/web/service/tgbot/tgbot_event.go
Normal file
150
internal/web/service/tgbot/tgbot_event.go
Normal file
@ -0,0 +1,150 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
|
||||
)
|
||||
|
||||
var cachedHostname string
|
||||
|
||||
func getHostname() string {
|
||||
if cachedHostname != "" {
|
||||
return cachedHostname
|
||||
}
|
||||
h, err := os.Hostname()
|
||||
if err != nil {
|
||||
cachedHostname = "unknown"
|
||||
} else {
|
||||
cachedHostname = h
|
||||
}
|
||||
return cachedHostname
|
||||
}
|
||||
|
||||
var tgEventLimiter = eventbus.NewRateLimiter(1 * time.Minute)
|
||||
|
||||
// HandleEvent is the eventbus subscriber callback. It formats incoming events
|
||||
// as Telegram messages and sends them to all admin chats.
|
||||
func (t *Tgbot) HandleEvent(e eventbus.Event) {
|
||||
if !t.isEventEnabled(e.Type) {
|
||||
return
|
||||
}
|
||||
if e.Type != eventbus.EventLoginAttempt {
|
||||
if !tgEventLimiter.Allow(e.Type, e.Source) {
|
||||
return
|
||||
}
|
||||
}
|
||||
msg := t.formatEventMessage(e)
|
||||
if msg != "" {
|
||||
t.SendMsgToTgbotAdmins(msg)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tgbot) isEventEnabled(eventType eventbus.EventType) bool {
|
||||
events, err := t.settingService.GetTgEnabledEvents()
|
||||
if err != nil || events == "" {
|
||||
return false
|
||||
}
|
||||
for _, e := range strings.Split(events, ",") {
|
||||
if strings.TrimSpace(e) == string(eventType) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (t *Tgbot) formatEventMessage(e eventbus.Event) string {
|
||||
host := getHostname()
|
||||
header := fmt.Sprintf("<b>📡 %s</b>\n", host)
|
||||
|
||||
switch e.Type {
|
||||
case eventbus.EventOutboundDown:
|
||||
msg := header + t.I18nBot("tgbot.messages.eventOutboundDown",
|
||||
"Tag=="+e.Source)
|
||||
if data, ok := e.Data.(*eventbus.OutboundHealthData); ok {
|
||||
if data.Error != "" {
|
||||
msg += "\n" + t.I18nBot("tgbot.messages.eventErrorDetail",
|
||||
"Error=="+data.Error)
|
||||
}
|
||||
if data.Delay > 0 {
|
||||
msg += "\n" + t.I18nBot("tgbot.messages.eventDelayDetail",
|
||||
"Delay=="+fmt.Sprintf("%d", data.Delay))
|
||||
}
|
||||
}
|
||||
return msg
|
||||
|
||||
case eventbus.EventOutboundUp:
|
||||
msg := header + t.I18nBot("tgbot.messages.eventOutboundUp",
|
||||
"Tag=="+e.Source)
|
||||
if data, ok := e.Data.(*eventbus.OutboundHealthData); ok && data.Delay > 0 {
|
||||
msg += "\n" + t.I18nBot("tgbot.messages.eventDelayDetail",
|
||||
"Delay=="+fmt.Sprintf("%d", data.Delay))
|
||||
}
|
||||
return msg
|
||||
|
||||
case eventbus.EventXrayCrash:
|
||||
errStr := ""
|
||||
if e.Data != nil {
|
||||
errStr = fmt.Sprint(e.Data)
|
||||
}
|
||||
msg := header + "🔥 " + t.I18nBot("tgbot.messages.eventXrayCrash")
|
||||
if errStr != "" {
|
||||
msg += "\n" + t.I18nBot("tgbot.messages.eventXrayCrashError", "Error=="+errStr)
|
||||
}
|
||||
return msg
|
||||
|
||||
case eventbus.EventNodeDown:
|
||||
msg := header + "🔴 " + t.I18nBot("tgbot.messages.eventNodeDown", "Name=="+e.Source)
|
||||
if data, ok := e.Data.(*eventbus.NodeHealthData); ok && data.XrayError != "" {
|
||||
msg += "\n" + t.I18nBot("tgbot.messages.eventErrorDetail", "Error=="+data.XrayError)
|
||||
}
|
||||
return msg
|
||||
|
||||
case eventbus.EventNodeUp:
|
||||
msg := header + "🟢 " + t.I18nBot("tgbot.messages.eventNodeUp", "Name=="+e.Source)
|
||||
if data, ok := e.Data.(*eventbus.NodeHealthData); ok && data.LatencyMs > 0 {
|
||||
msg += "\n" + t.I18nBot("tgbot.messages.eventDelayDetail", "Delay=="+fmt.Sprintf("%d", data.LatencyMs))
|
||||
}
|
||||
return msg
|
||||
|
||||
case eventbus.EventCPUHigh:
|
||||
if data, ok := e.Data.(*eventbus.SystemMetricData); ok {
|
||||
tgCpu, err := t.settingService.GetTgCpu()
|
||||
if err != nil || tgCpu <= 0 || data.Percent <= float64(tgCpu) {
|
||||
return ""
|
||||
}
|
||||
return header + "🔴 " + t.I18nBot("tgbot.messages.cpuThreshold",
|
||||
"Percent=="+strconv.FormatFloat(data.Percent, 'f', 2, 64),
|
||||
"Threshold=="+strconv.Itoa(tgCpu))
|
||||
}
|
||||
return ""
|
||||
|
||||
case eventbus.EventLoginAttempt:
|
||||
if data, ok := e.Data.(*eventbus.LoginEventData); ok {
|
||||
if data.Status == "success" {
|
||||
msg := t.I18nBot("tgbot.messages.loginSuccess")
|
||||
msg += t.I18nBot("tgbot.messages.hostname", "Hostname=="+host)
|
||||
msg += t.I18nBot("tgbot.messages.username", "Username=="+data.Username)
|
||||
msg += t.I18nBot("tgbot.messages.ip", "IP=="+data.IP)
|
||||
msg += t.I18nBot("tgbot.messages.time", "Time=="+data.Time)
|
||||
return msg
|
||||
}
|
||||
msg := t.I18nBot("tgbot.messages.loginFailed")
|
||||
msg += t.I18nBot("tgbot.messages.hostname", "Hostname=="+host)
|
||||
if data.Reason != "" {
|
||||
msg += t.I18nBot("tgbot.messages.reason", "Reason=="+data.Reason)
|
||||
}
|
||||
msg += t.I18nBot("tgbot.messages.username", "Username=="+data.Username)
|
||||
msg += t.I18nBot("tgbot.messages.ip", "IP=="+data.IP)
|
||||
msg += t.I18nBot("tgbot.messages.time", "Time=="+data.Time)
|
||||
return msg
|
||||
}
|
||||
return header + t.I18nBot("tgbot.messages.eventLoginFallback", "Source=="+e.Source)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
@ -12,6 +12,7 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
@ -153,38 +154,33 @@ func (t *Tgbot) prepareServerUsageInfo() string {
|
||||
return info
|
||||
}
|
||||
|
||||
// UserLoginNotify sends a notification about user login attempts to admins.
|
||||
// UserLoginNotify publishes a login event to the event bus.
|
||||
func (t *Tgbot) UserLoginNotify(attempt LoginAttempt) {
|
||||
if !t.IsRunning() {
|
||||
return
|
||||
}
|
||||
|
||||
if attempt.Username == "" || attempt.IP == "" || attempt.Time == "" {
|
||||
logger.Warning("UserLoginNotify failed, invalid info!")
|
||||
return
|
||||
}
|
||||
|
||||
loginNotifyEnabled, err := t.settingService.GetTgBotLoginNotify()
|
||||
if err != nil || !loginNotifyEnabled {
|
||||
if EventBus == nil {
|
||||
return
|
||||
}
|
||||
|
||||
msg := ""
|
||||
switch attempt.Status {
|
||||
case LoginSuccess:
|
||||
msg += t.I18nBot("tgbot.messages.loginSuccess")
|
||||
msg += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
|
||||
case LoginFail:
|
||||
msg += t.I18nBot("tgbot.messages.loginFailed")
|
||||
msg += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
|
||||
if attempt.Reason != "" {
|
||||
msg += t.I18nBot("tgbot.messages.reason", "Reason=="+attempt.Reason)
|
||||
}
|
||||
status := "fail"
|
||||
if attempt.Status == LoginSuccess {
|
||||
status = "success"
|
||||
}
|
||||
msg += t.I18nBot("tgbot.messages.username", "Username=="+attempt.Username)
|
||||
msg += t.I18nBot("tgbot.messages.ip", "IP=="+attempt.IP)
|
||||
msg += t.I18nBot("tgbot.messages.time", "Time=="+attempt.Time)
|
||||
go t.SendMsgToTgbotAdmins(msg)
|
||||
|
||||
EventBus.Publish(eventbus.Event{
|
||||
Type: eventbus.EventLoginAttempt,
|
||||
Source: attempt.IP,
|
||||
Data: &eventbus.LoginEventData{
|
||||
Username: attempt.Username,
|
||||
IP: attempt.IP,
|
||||
Time: attempt.Time,
|
||||
Status: status,
|
||||
Reason: attempt.Reason,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// getExhausted retrieves and sends information about exhausted clients.
|
||||
|
||||
@ -2,6 +2,7 @@ package tgbot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -247,3 +248,19 @@ func (t *Tgbot) deleteMessageTgBot(chatId int64, messageID int) {
|
||||
logger.Info("Message deleted successfully")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnection verifies the bot token is valid and the API is reachable.
|
||||
func (t *Tgbot) TestConnection() error {
|
||||
tgBotMutex.Lock()
|
||||
b := bot
|
||||
tgBotMutex.Unlock()
|
||||
if b == nil {
|
||||
return fmt.Errorf("bot not initialized")
|
||||
}
|
||||
me, err := b.GetMe(context.Background())
|
||||
if err != nil {
|
||||
return fmt.Errorf("API unreachable: %w", err)
|
||||
}
|
||||
_ = me
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
)
|
||||
|
||||
@ -29,6 +30,15 @@ type ObsTagSnapshot struct {
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// eventBus is the shared bus for publishing observatory state-change events.
|
||||
// Set once during startup via SetEventBus; nil when no bus is configured.
|
||||
var eventBus *eventbus.Bus
|
||||
|
||||
// SetEventBus assigns the global event bus used by applyObservatory to publish
|
||||
// outbound health transitions. Must be called once during startup before any
|
||||
// Sample tick runs.
|
||||
func SetEventBus(b *eventbus.Bus) { eventBus = b }
|
||||
|
||||
type XrayMetricsService struct {
|
||||
settingService SettingService
|
||||
|
||||
@ -205,6 +215,35 @@ func (s *XrayMetricsService) applyObservatory(t time.Time, entries map[string]ra
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
// Detect transitions and publish events
|
||||
if eventBus != nil {
|
||||
// Check existing tags for state changes
|
||||
for tag, old := range s.obsByTag {
|
||||
cur, exists := next[tag]
|
||||
if !exists {
|
||||
// Tag disappeared from observatory — skip, not a real failure
|
||||
continue
|
||||
}
|
||||
if old.Alive && !cur.Alive {
|
||||
errMsg := ""
|
||||
if cur.Delay < 0 {
|
||||
errMsg = "probe failed"
|
||||
}
|
||||
eventBus.Publish(eventbus.Event{
|
||||
Type: eventbus.EventOutboundDown,
|
||||
Source: tag,
|
||||
Data: &eventbus.OutboundHealthData{Delay: cur.Delay, Error: errMsg},
|
||||
})
|
||||
} else if !old.Alive && cur.Alive {
|
||||
eventBus.Publish(eventbus.Event{
|
||||
Type: eventbus.EventOutboundUp,
|
||||
Source: tag,
|
||||
Data: &eventbus.OutboundHealthData{Delay: cur.Delay},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for tag := range s.obsByTag {
|
||||
if _, kept := next[tag]; !kept {
|
||||
xrayMetrics.drop(obsHistoryKey(tag))
|
||||
|
||||
@ -557,6 +557,15 @@
|
||||
"tcpCongestion": "TCP Congestion",
|
||||
"dialerProxy": "Dialer Proxy",
|
||||
"trustedXForwardedFor": "X-Forwarded-For موثوق",
|
||||
"trustedXForwardedForHint": "ثِق بترويسة الطلب هذه للحصول على IP الحقيقي للعميل (مثل CF-Connecting-IP خلف CDN الخاص بـ Cloudflare). تعمل فقط على وسائل النقل WebSocket و HTTPUpgrade و XHTTP. اتركها فارغة لتجاهل ترويسات التمرير.",
|
||||
"proxyProtocolHint": "اقبل ترويسة PROXY protocol لمعرفة IP الحقيقي للعميل من نفق/مُرحِّل L4 أعلى (HAProxy و gost و nginx-stream و Xray dokodemo-door) أو Cloudflare Spectrum. يجب على الجهة الأعلى إرسال PROXY protocol. تعمل على TCP و WebSocket و HTTPUpgrade و gRPC؛ ولا تعمل على mKCP.",
|
||||
"realClientIp": "IP الحقيقي للعميل",
|
||||
"realClientIpHint": "احصل على IP الحقيقي للزائر عندما يصل المرور إلى هذا الـ inbound عبر CDN أو مُرحِّل، بدلاً من تسجيل عنوان الوسيط. اختر إعدادًا مسبقًا لملء حقول sockopt المقابلة أدناه. لا تُرسَل هذه الحقول أبدًا إلى العملاء في الاشتراكات.",
|
||||
"realClientIpPresetOff": "إيقاف / مباشر",
|
||||
"realClientIpPresetCloudflare": "Cloudflare CDN",
|
||||
"realClientIpPresetProxyProtocol": "مُرحِّل L4 / Spectrum (PROXY)",
|
||||
"realClientIpTrustedHeaderTransportWarn": "Trusted X-Forwarded-For تعمل فقط على WebSocket و HTTPUpgrade و XHTTP. على وسيلة النقل الحالية يتم تجاهل هذه الترويسة.",
|
||||
"realClientIpProxyProtocolTransportWarn": "PROXY protocol غير مدعوم على وسيلة النقل هذه (mKCP). استخدم TCP/RAW أو WebSocket أو HTTPUpgrade أو gRPC أو XHTTP.",
|
||||
"addressPortStrategy": "استراتيجية العنوان+المنفذ",
|
||||
"tryDelayMs": "تأخير المحاولة (ms)",
|
||||
"prioritizeIPv6": "أولوية IPv6",
|
||||
@ -1200,7 +1209,69 @@
|
||||
"userPassMustBeNotEmpty": "اسم المستخدم والباسورد الجديدين فاضيين",
|
||||
"getOutboundTrafficError": "خطأ في الحصول على حركات المرور الصادرة",
|
||||
"resetOutboundTrafficError": "خطأ في إعادة تعيين حركات المرور الصادرة"
|
||||
}
|
||||
},
|
||||
"emailNotifications": "الإشعارات",
|
||||
"emailSettings": "البريد الإلكتروني",
|
||||
"eventCPUHigh": "ارتفاع استخدام المعالج (%)",
|
||||
"eventGroupOutbound": "الصادر",
|
||||
"eventGroupSecurity": "الأمان",
|
||||
"eventGroupSystem": "النظام",
|
||||
"eventGroupXray": "نواة Xray",
|
||||
"eventLoginAttempt": "محاولة تسجيل دخول",
|
||||
"eventOutboundDown": "غير متصل",
|
||||
"eventOutboundUp": "متصل",
|
||||
"eventXrayCrash": "تعطّل",
|
||||
"requestFailed": "فشل الطلب",
|
||||
"smtpEnable": "تفعيل إشعارات البريد الإلكتروني",
|
||||
"smtpEnableDesc": "تفعيل إشعارات البريد الإلكتروني عبر SMTP",
|
||||
"smtpEncryption": "التشفير",
|
||||
"smtpEncryptionDesc": "طريقة تشفير اتصال SMTP",
|
||||
"smtpEncryptionNone": "بدون (نص عادي)",
|
||||
"smtpEncryptionStartTLS": "STARTTLS",
|
||||
"smtpEncryptionTLS": "TLS (ضمني)",
|
||||
"smtpEventBusNotify": "إشعارات الأحداث بالبريد الإلكتروني",
|
||||
"smtpEventBusNotifyDesc": "اختر الأحداث التي تُطلق إشعارات البريد الإلكتروني",
|
||||
"smtpHost": "خادم SMTP",
|
||||
"smtpHostDesc": "اسم مضيف خادم SMTP (مثال: smtp.gmail.com)",
|
||||
"smtpHostNotConfigured": "خادم SMTP غير مهيأ",
|
||||
"smtpNoRecipients": "لا يوجد مستلمون مهيؤون",
|
||||
"smtpNotInitialized": "لم تتم تهيئة SMTP",
|
||||
"smtpPassword": "كلمة مرور SMTP",
|
||||
"smtpPasswordDesc": "كلمة المرور للمصادقة على SMTP",
|
||||
"smtpPort": "منفذ SMTP",
|
||||
"smtpPortDesc": "منفذ خادم SMTP (الافتراضي: 587)",
|
||||
"smtpSettings": "إعدادات SMTP",
|
||||
"smtpStageAuth": "المصادقة",
|
||||
"smtpStageConnect": "الاتصال",
|
||||
"smtpStageSend": "الإرسال",
|
||||
"smtpTestSuccess": "تم إرسال البريد التجريبي بنجاح",
|
||||
"smtpTo": "المستلمون",
|
||||
"smtpToDesc": "عناوين البريد الإلكتروني للمستلمين مفصولة بفواصل",
|
||||
"smtpUsername": "اسم مستخدم SMTP",
|
||||
"smtpUsernameDesc": "اسم المستخدم للمصادقة على SMTP",
|
||||
"telegramTokenConfigured": "مهيأ؛ اتركه فارغاً للاحتفاظ بالتوكن الحالي.",
|
||||
"telegramTokenPlaceholder": "مهيأ — أدخل توكن جديد لاستبداله",
|
||||
"testSmtp": "إرسال بريد تجريبي",
|
||||
"testTgBot": "إرسال رسالة تجريبية",
|
||||
"tgBotNotEnabled": "بوت Telegram غير مفعّل",
|
||||
"tgBotNotRunning": "بوت Telegram لا يعمل",
|
||||
"tgEventBusNotify": "إشعارات الأحداث عبر Telegram",
|
||||
"tgEventBusNotifyDesc": "اختر الأحداث التي تُطلق إشعارات Telegram",
|
||||
"tgTestFailed": "فشل اختبار Telegram",
|
||||
"tgTestSuccess": "تم إرسال رسالة تجريبية إلى Telegram",
|
||||
"smtpErrorAuth": "فشلت المصادقة — تحقق من اسم المستخدم وكلمة المرور",
|
||||
"smtpErrorStarttls": "الخادم يتطلب STARTTLS — غيّر نوع التشفير",
|
||||
"smtpErrorTls": "الخادم يتطلب TLS — غيّر نوع التشفير",
|
||||
"smtpErrorRefused": "تم رفض الاتصال — تحقق من الخادم والمنفذ",
|
||||
"smtpErrorTimeout": "انتهت مهلة الاتصال — تعذّر الوصول إلى الخادم",
|
||||
"smtpErrorRelay": "الخادم يرفض الإرسال من هذا العنوان",
|
||||
"smtpErrorEof": "تم إغلاق الاتصال من قبل الخادم",
|
||||
"smtpErrorUnknown": "خطأ SMTP: {{ .Error }}",
|
||||
"eventGroupNode": "العقد",
|
||||
"eventNodeDown": "غير متصلة",
|
||||
"eventNodeUp": "متصلة",
|
||||
"smtpPasswordConfigured": "مهيأة؛ اتركها فارغة للاحتفاظ بكلمة المرور الحالية.",
|
||||
"smtpPasswordPlaceholder": "مهيأة — أدخل كلمة مرور جديدة لاستبدالها"
|
||||
},
|
||||
"xray": {
|
||||
"title": "إعدادات Xray",
|
||||
@ -1703,7 +1774,18 @@
|
||||
"AreYouSure": "إنت متأكد؟ 🤔",
|
||||
"SuccessResetTraffic": "📧 البريد الإلكتروني: {{ .ClientEmail }}\n🏁 النتيجة: ✅ تم بنجاح",
|
||||
"FailedResetTraffic": "📧 البريد الإلكتروني: {{ .ClientEmail }}\n🏁 النتيجة: ❌ فشل \n\n🛠️ الخطأ: [ {{ .ErrorMessage }} ]",
|
||||
"FinishProcess": "🔚 عملية إعادة ضبط الترافيك خلصت لكل العملاء."
|
||||
"FinishProcess": "🔚 عملية إعادة ضبط الترافيك خلصت لكل العملاء.",
|
||||
"eventCPUHigh": "ارتفاع استخدام المعالج",
|
||||
"eventCPUHighDetail": "المعالج: {{ .Detail }}",
|
||||
"eventDelayDetail": "التأخير: {{ .Delay }} مللي ثانية",
|
||||
"eventErrorDetail": "الخطأ: {{ .Error }}",
|
||||
"eventLoginFallback": "فشل تسجيل الدخول من {{ .Source }}",
|
||||
"eventOutboundDown": "الصادر {{ .Tag }} غير متصل",
|
||||
"eventOutboundUp": "الصادر {{ .Tag }} متصل",
|
||||
"eventXrayCrash": "تعطّل Xray",
|
||||
"eventXrayCrashError": "الخطأ: {{ .Error }}",
|
||||
"eventNodeDown": "العقدة {{ .Name }} غير متصلة",
|
||||
"eventNodeUp": "العقدة {{ .Name }} متصلة"
|
||||
},
|
||||
"buttons": {
|
||||
"closeKeyboard": "❌ اقفل الكيبورد",
|
||||
@ -1773,5 +1855,57 @@
|
||||
"chooseClient": "اختار عميل للإدخال {{ .Inbound }}",
|
||||
"chooseInbound": "اختار الإدخال"
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"labelDelay": "التأخير",
|
||||
"labelDetail": "التفاصيل",
|
||||
"labelError": "الخطأ",
|
||||
"labelIP": "IP",
|
||||
"labelOutbound": "الصادر",
|
||||
"labelReason": "السبب",
|
||||
"labelSource": "المصدر",
|
||||
"labelStatus": "الحالة",
|
||||
"labelTime": "الوقت",
|
||||
"labelUsername": "اسم المستخدم",
|
||||
"statusBanned": "BANNED",
|
||||
"statusCrashed": "متعطّل",
|
||||
"statusDown": "غير متصل",
|
||||
"statusFailed": "فشل",
|
||||
"statusFull": "FULL",
|
||||
"statusHigh": "مرتفع",
|
||||
"statusOffline": "OFFLINE",
|
||||
"statusOnline": "ONLINE",
|
||||
"statusRunning": "يعمل",
|
||||
"statusSuccess": "نجاح",
|
||||
"statusUp": "متصل",
|
||||
"statusXrayDown": "Xray DOWN",
|
||||
"statusXrayUp": "Xray UP",
|
||||
"subjectCPUHigh": "ارتفاع استخدام المعالج",
|
||||
"subjectDiskFull": "Disk full",
|
||||
"subjectIPBanned": "IP banned: {{ .IP }}",
|
||||
"subjectLoginFailed": "فشل تسجيل الدخول",
|
||||
"subjectLoginSuccess": "نجح تسجيل الدخول",
|
||||
"subjectNodeOffline": "Node {{ .Node }} is OFFLINE",
|
||||
"subjectNodeOnline": "Node {{ .Node }} is ONLINE",
|
||||
"subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN",
|
||||
"subjectNodeXrayUp": "Node {{ .Node }} Xray is UP",
|
||||
"subjectOutboundDown": "الصادر {{ .Tag }} غير متصل",
|
||||
"subjectOutboundUp": "الصادر {{ .Tag }} متصل",
|
||||
"subjectXrayCrash": "تعطّل Xray",
|
||||
"subjectXrayUp": "Xray is UP",
|
||||
"titleCPUHigh": "ارتفاع استخدام المعالج",
|
||||
"titleDiskFull": "Disk full",
|
||||
"titleIPBanned": "IP banned",
|
||||
"titleLoginFailed": "فشل تسجيل الدخول",
|
||||
"titleLoginSuccess": "نجح تسجيل الدخول",
|
||||
"titleNodeOffline": "Node OFFLINE",
|
||||
"titleNodeOnline": "Node ONLINE",
|
||||
"titleNodeXrayDown": "Node Xray DOWN",
|
||||
"titleNodeXrayUp": "Node Xray UP",
|
||||
"titleOutboundDown": "الصادر غير متصل",
|
||||
"titleOutboundUp": "الصادر متصل",
|
||||
"titleXrayCrash": "تعطّل Xray",
|
||||
"titleXrayUp": "Xray UP",
|
||||
"labelNode": "العقدة"
|
||||
}
|
||||
}
|
||||
@ -558,6 +558,15 @@
|
||||
"tcpCongestion": "TCP Congestion",
|
||||
"dialerProxy": "Dialer Proxy",
|
||||
"trustedXForwardedFor": "Trusted X-Forwarded-For",
|
||||
"trustedXForwardedForHint": "Trust this request header for the real client IP (e.g. CF-Connecting-IP behind Cloudflare's CDN). Only honored on WebSocket, HTTPUpgrade and XHTTP transports. Leave empty to ignore forwarded headers.",
|
||||
"proxyProtocolHint": "Accept the PROXY-protocol header to learn the real client IP from an upstream L4 tunnel or relay (HAProxy, gost, nginx-stream, Xray dokodemo-door) or Cloudflare Spectrum. The upstream MUST emit PROXY protocol. Works on TCP, WebSocket, HTTPUpgrade and gRPC; not on mKCP.",
|
||||
"realClientIp": "Real client IP",
|
||||
"realClientIpHint": "Capture the visitor's real IP when traffic reaches this inbound through a CDN or relay, instead of recording the intermediary's address. Pick a preset to fill the matching sockopt fields below. These fields are never sent to clients in subscriptions.",
|
||||
"realClientIpPresetOff": "Off / direct",
|
||||
"realClientIpPresetCloudflare": "Cloudflare CDN",
|
||||
"realClientIpPresetProxyProtocol": "L4 relay / Spectrum (PROXY)",
|
||||
"realClientIpTrustedHeaderTransportWarn": "Trusted X-Forwarded-For is only honored on WebSocket, HTTPUpgrade and XHTTP. On the current transport this header is ignored.",
|
||||
"realClientIpProxyProtocolTransportWarn": "PROXY protocol is not supported on this transport (mKCP). Use TCP/RAW, WebSocket, HTTPUpgrade, gRPC or XHTTP.",
|
||||
"addressPortStrategy": "Address+port strategy",
|
||||
"tryDelayMs": "Try delay (ms)",
|
||||
"prioritizeIPv6": "Prioritize IPv6",
|
||||
@ -1201,7 +1210,69 @@
|
||||
"userPassMustBeNotEmpty": "The new username and password are empty",
|
||||
"getOutboundTrafficError": "Error getting traffic",
|
||||
"resetOutboundTrafficError": "Error resetting outbound traffic"
|
||||
}
|
||||
},
|
||||
"smtpSettings": "SMTP Settings",
|
||||
"smtpEnable": "Enable Email Notifications",
|
||||
"smtpEnableDesc": "Enable email notifications via SMTP",
|
||||
"smtpHost": "SMTP Host",
|
||||
"smtpHostDesc": "SMTP server hostname (e.g. smtp.gmail.com)",
|
||||
"smtpPort": "SMTP Port",
|
||||
"smtpPortDesc": "SMTP server port (default: 587)",
|
||||
"smtpUsername": "SMTP Username",
|
||||
"smtpUsernameDesc": "SMTP authentication username",
|
||||
"smtpPassword": "SMTP Password",
|
||||
"smtpPasswordDesc": "SMTP authentication password",
|
||||
"smtpTo": "Recipients",
|
||||
"smtpToDesc": "Comma-separated recipient email addresses",
|
||||
"emailSettings": "Email",
|
||||
"emailNotifications": "Notifications",
|
||||
"smtpEventBusNotify": "Email Event Notifications",
|
||||
"smtpEventBusNotifyDesc": "Select which events trigger email notifications",
|
||||
"tgEventBusNotify": "Telegram Event Notifications",
|
||||
"tgEventBusNotifyDesc": "Select which events trigger Telegram notifications",
|
||||
"testSmtp": "Send Test Email",
|
||||
"testTgBot": "Send Test Message",
|
||||
"eventGroupOutbound": "Outbound",
|
||||
"eventGroupXray": "Xray Core",
|
||||
"eventGroupSystem": "System",
|
||||
"eventGroupSecurity": "Security",
|
||||
"eventGroupNode": "Nodes",
|
||||
"eventOutboundDown": "Down",
|
||||
"eventOutboundUp": "Up",
|
||||
"eventXrayCrash": "Crash",
|
||||
"eventNodeDown": "Down",
|
||||
"eventNodeUp": "Up",
|
||||
"eventCPUHigh": "CPU high (%)",
|
||||
"requestFailed": "Request failed",
|
||||
"smtpEncryption": "Encryption",
|
||||
"smtpEncryptionDesc": "SMTP connection encryption method",
|
||||
"smtpEncryptionNone": "None (plain text)",
|
||||
"smtpEncryptionStartTLS": "STARTTLS",
|
||||
"smtpEncryptionTLS": "TLS (implicit)",
|
||||
"smtpStageConnect": "Connection",
|
||||
"smtpStageAuth": "Authentication",
|
||||
"smtpStageSend": "Send",
|
||||
"smtpTestSuccess": "Test email sent successfully",
|
||||
"smtpHostNotConfigured": "SMTP host not configured",
|
||||
"smtpNoRecipients": "No recipients configured",
|
||||
"eventLoginAttempt": "Login attempt",
|
||||
"telegramTokenConfigured": "Configured; leave blank to keep current token.",
|
||||
"telegramTokenPlaceholder": "Configured - enter a new token to replace",
|
||||
"smtpPasswordConfigured": "Configured; leave blank to keep current password.",
|
||||
"smtpPasswordPlaceholder": "Configured - enter a new password to replace",
|
||||
"smtpNotInitialized": "SMTP not initialized",
|
||||
"tgBotNotEnabled": "Telegram bot is not enabled",
|
||||
"tgTestFailed": "Telegram test failed",
|
||||
"tgTestSuccess": "Test message sent to Telegram",
|
||||
"tgBotNotRunning": "Telegram bot not running",
|
||||
"smtpErrorAuth": "Authentication failed — check username and password",
|
||||
"smtpErrorStarttls": "Server requires STARTTLS — change encryption type",
|
||||
"smtpErrorTls": "Server requires TLS — change encryption type",
|
||||
"smtpErrorRefused": "Connection refused — check host and port",
|
||||
"smtpErrorTimeout": "Connection timeout — host unreachable",
|
||||
"smtpErrorRelay": "Server rejects sending from this address",
|
||||
"smtpErrorEof": "Connection closed by server",
|
||||
"smtpErrorUnknown": "SMTP error: {{ .Error }}"
|
||||
},
|
||||
"xray": {
|
||||
"title": "Xray Configs",
|
||||
@ -1704,7 +1775,18 @@
|
||||
"AreYouSure": "Are you sure? 🤔",
|
||||
"SuccessResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Result: ✅ Success",
|
||||
"FailedResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Result: ❌ Failed \n\n🛠️ Error: [ {{ .ErrorMessage }} ]",
|
||||
"FinishProcess": "🔚 Traffic reset process finished for all clients."
|
||||
"FinishProcess": "🔚 Traffic reset process finished for all clients.",
|
||||
"eventOutboundDown": "Outbound {{ .Tag }} is DOWN",
|
||||
"eventOutboundUp": "Outbound {{ .Tag }} is UP",
|
||||
"eventErrorDetail": "Error: {{ .Error }}",
|
||||
"eventDelayDetail": "Delay: {{ .Delay }}ms",
|
||||
"eventXrayCrash": "Xray CRASHED",
|
||||
"eventXrayCrashError": "Error: {{ .Error }}",
|
||||
"eventNodeDown": "Node {{ .Name }} is DOWN",
|
||||
"eventNodeUp": "Node {{ .Name }} is UP",
|
||||
"eventCPUHigh": "CPU high",
|
||||
"eventCPUHighDetail": "CPU: {{ .Detail }}",
|
||||
"eventLoginFallback": "Login failed from {{ .Source }}"
|
||||
},
|
||||
"buttons": {
|
||||
"closeKeyboard": "❌ Close Keyboard",
|
||||
@ -1774,5 +1856,37 @@
|
||||
"chooseClient": "Choose a Client for Inbound {{ .Inbound }}",
|
||||
"chooseInbound": "Choose an Inbound"
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"subjectOutboundDown": "Outbound {{ .Tag }} is DOWN",
|
||||
"subjectOutboundUp": "Outbound {{ .Tag }} is UP",
|
||||
"subjectXrayCrash": "Xray CRASHED",
|
||||
"subjectCPUHigh": "CPU high",
|
||||
"subjectLoginSuccess": "Login successful",
|
||||
"subjectLoginFailed": "Login failed",
|
||||
"titleOutboundDown": "Outbound DOWN",
|
||||
"titleOutboundUp": "Outbound UP",
|
||||
"titleXrayCrash": "Xray CRASHED",
|
||||
"titleCPUHigh": "CPU high",
|
||||
"titleLoginSuccess": "Login successful",
|
||||
"titleLoginFailed": "Login failed",
|
||||
"labelStatus": "Status",
|
||||
"labelOutbound": "Outbound",
|
||||
"labelNode": "Node",
|
||||
"labelError": "Error",
|
||||
"labelDelay": "Delay",
|
||||
"labelDetail": "Detail",
|
||||
"labelUsername": "Username",
|
||||
"labelIP": "IP",
|
||||
"labelReason": "Reason",
|
||||
"labelSource": "Source",
|
||||
"labelTime": "Time",
|
||||
"statusCrashed": "CRASHED",
|
||||
"statusRunning": "Running",
|
||||
"statusHigh": "HIGH",
|
||||
"statusSuccess": "SUCCESS",
|
||||
"statusFailed": "FAILED",
|
||||
"statusDown": "DOWN",
|
||||
"statusUp": "UP"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -557,6 +557,15 @@
|
||||
"tcpCongestion": "TCP Congestion",
|
||||
"dialerProxy": "Dialer Proxy",
|
||||
"trustedXForwardedFor": "X-Forwarded-For de confianza",
|
||||
"trustedXForwardedForHint": "Confía en esta cabecera de solicitud para obtener la IP real del cliente (p. ej. CF-Connecting-IP detrás del CDN de Cloudflare). Solo válido en los transportes WebSocket, HTTPUpgrade y XHTTP. Déjalo vacío para ignorar las cabeceras reenviadas.",
|
||||
"proxyProtocolHint": "Acepta la cabecera PROXY protocol para obtener la IP real del cliente desde un túnel/relé L4 superior (HAProxy, gost, nginx-stream, Xray dokodemo-door) o Cloudflare Spectrum. El nodo superior DEBE enviar PROXY protocol. Funciona en TCP, WebSocket, HTTPUpgrade y gRPC; no en mKCP.",
|
||||
"realClientIp": "IP real del cliente",
|
||||
"realClientIpHint": "Captura la IP real del visitante cuando el tráfico llega a este inbound a través de un CDN o relé, en lugar de registrar la dirección del intermediario. Elige un preajuste para rellenar los campos sockopt correspondientes más abajo. Estos campos nunca se envían a los clientes en las suscripciones.",
|
||||
"realClientIpPresetOff": "Desactivado / directo",
|
||||
"realClientIpPresetCloudflare": "Cloudflare CDN",
|
||||
"realClientIpPresetProxyProtocol": "Relé L4 / Spectrum (PROXY)",
|
||||
"realClientIpTrustedHeaderTransportWarn": "Trusted X-Forwarded-For solo es válido en WebSocket, HTTPUpgrade y XHTTP. En el transporte actual esta cabecera se ignora.",
|
||||
"realClientIpProxyProtocolTransportWarn": "PROXY protocol no es compatible con este transporte (mKCP). Usa TCP/RAW, WebSocket, HTTPUpgrade, gRPC o XHTTP.",
|
||||
"addressPortStrategy": "Estrategia dirección+puerto",
|
||||
"tryDelayMs": "Retraso de intento (ms)",
|
||||
"prioritizeIPv6": "Priorizar IPv6",
|
||||
@ -1200,7 +1209,69 @@
|
||||
"userPassMustBeNotEmpty": "El nuevo nombre de usuario y la nueva contraseña no pueden estar vacíos",
|
||||
"getOutboundTrafficError": "Error al obtener el tráfico saliente",
|
||||
"resetOutboundTrafficError": "Error al reiniciar el tráfico saliente"
|
||||
}
|
||||
},
|
||||
"emailNotifications": "Notificaciones",
|
||||
"emailSettings": "Correo",
|
||||
"eventCPUHigh": "CPU alta (%)",
|
||||
"eventGroupOutbound": "Saliente",
|
||||
"eventGroupSecurity": "Seguridad",
|
||||
"eventGroupSystem": "Sistema",
|
||||
"eventGroupXray": "Núcleo de Xray",
|
||||
"eventLoginAttempt": "Intento de inicio de sesión",
|
||||
"eventOutboundDown": "Caído",
|
||||
"eventOutboundUp": "Activo",
|
||||
"eventXrayCrash": "Caída",
|
||||
"requestFailed": "La solicitud falló",
|
||||
"smtpEnable": "Activar notificaciones por correo",
|
||||
"smtpEnableDesc": "Activar notificaciones por correo mediante SMTP",
|
||||
"smtpEncryption": "Cifrado",
|
||||
"smtpEncryptionDesc": "Método de cifrado de la conexión SMTP",
|
||||
"smtpEncryptionNone": "Ninguno (texto sin cifrar)",
|
||||
"smtpEncryptionStartTLS": "STARTTLS",
|
||||
"smtpEncryptionTLS": "TLS (implícito)",
|
||||
"smtpEventBusNotify": "Notificaciones por correo de eventos",
|
||||
"smtpEventBusNotifyDesc": "Seleccione qué eventos generan notificaciones por correo",
|
||||
"smtpHost": "Servidor SMTP",
|
||||
"smtpHostDesc": "Nombre del servidor SMTP (p. ej. smtp.gmail.com)",
|
||||
"smtpHostNotConfigured": "Servidor SMTP no configurado",
|
||||
"smtpNoRecipients": "No hay destinatarios configurados",
|
||||
"smtpNotInitialized": "SMTP no inicializado",
|
||||
"smtpPassword": "Contraseña SMTP",
|
||||
"smtpPasswordDesc": "Contraseña de autenticación SMTP",
|
||||
"smtpPort": "Puerto SMTP",
|
||||
"smtpPortDesc": "Puerto del servidor SMTP (predeterminado: 587)",
|
||||
"smtpSettings": "Configuración de SMTP",
|
||||
"smtpStageAuth": "Autenticación",
|
||||
"smtpStageConnect": "Conexión",
|
||||
"smtpStageSend": "Envío",
|
||||
"smtpTestSuccess": "Correo de prueba enviado correctamente",
|
||||
"smtpTo": "Destinatarios",
|
||||
"smtpToDesc": "Direcciones de correo de los destinatarios separadas por comas",
|
||||
"smtpUsername": "Usuario SMTP",
|
||||
"smtpUsernameDesc": "Usuario de autenticación SMTP",
|
||||
"telegramTokenConfigured": "Configurado; deje en blanco para mantener el token actual.",
|
||||
"telegramTokenPlaceholder": "Configurado: introduzca un nuevo token para reemplazarlo",
|
||||
"testSmtp": "Enviar correo de prueba",
|
||||
"testTgBot": "Enviar mensaje de prueba",
|
||||
"tgBotNotEnabled": "El bot de Telegram no está activado",
|
||||
"tgBotNotRunning": "El bot de Telegram no está en ejecución",
|
||||
"tgEventBusNotify": "Notificaciones de Telegram de eventos",
|
||||
"tgEventBusNotifyDesc": "Seleccione qué eventos generan notificaciones de Telegram",
|
||||
"tgTestFailed": "La prueba de Telegram falló",
|
||||
"tgTestSuccess": "Mensaje de prueba enviado a Telegram",
|
||||
"smtpErrorAuth": "Error de autenticación: compruebe el usuario y la contraseña",
|
||||
"smtpErrorStarttls": "El servidor requiere STARTTLS: cambie el tipo de cifrado",
|
||||
"smtpErrorTls": "El servidor requiere TLS: cambie el tipo de cifrado",
|
||||
"smtpErrorRefused": "Conexión rechazada: compruebe el servidor y el puerto",
|
||||
"smtpErrorTimeout": "Tiempo de conexión agotado: servidor inaccesible",
|
||||
"smtpErrorRelay": "El servidor rechaza el envío desde esta dirección",
|
||||
"smtpErrorEof": "Conexión cerrada por el servidor",
|
||||
"smtpErrorUnknown": "Error de SMTP: {{ .Error }}",
|
||||
"eventGroupNode": "Nodos",
|
||||
"eventNodeDown": "Caído",
|
||||
"eventNodeUp": "Activo",
|
||||
"smtpPasswordConfigured": "Configurada; deje en blanco para mantener la contraseña actual.",
|
||||
"smtpPasswordPlaceholder": "Configurada: introduzca una nueva contraseña para reemplazarla"
|
||||
},
|
||||
"xray": {
|
||||
"title": "Xray Configuración",
|
||||
@ -1703,7 +1774,18 @@
|
||||
"AreYouSure": "¿Estás seguro? 🤔",
|
||||
"SuccessResetTraffic": "📧 Correo: {{ .ClientEmail }}\n🏁 Resultado: ✅ Éxito",
|
||||
"FailedResetTraffic": "📧 Correo: {{ .ClientEmail }}\n🏁 Resultado: ❌ Fallido \n\n🛠️ Error: [ {{ .ErrorMessage }} ]",
|
||||
"FinishProcess": "🔚 Proceso de reinicio de tráfico finalizado para todos los clientes."
|
||||
"FinishProcess": "🔚 Proceso de reinicio de tráfico finalizado para todos los clientes.",
|
||||
"eventCPUHigh": "CPU alta",
|
||||
"eventCPUHighDetail": "CPU: {{ .Detail }}",
|
||||
"eventDelayDetail": "Retardo: {{ .Delay }} ms",
|
||||
"eventErrorDetail": "Error: {{ .Error }}",
|
||||
"eventLoginFallback": "Inicio de sesión fallido desde {{ .Source }}",
|
||||
"eventOutboundDown": "El saliente {{ .Tag }} está CAÍDO",
|
||||
"eventOutboundUp": "El saliente {{ .Tag }} está ACTIVO",
|
||||
"eventXrayCrash": "Xray se ha BLOQUEADO",
|
||||
"eventXrayCrashError": "Error: {{ .Error }}",
|
||||
"eventNodeDown": "El nodo {{ .Name }} está CAÍDO",
|
||||
"eventNodeUp": "El nodo {{ .Name }} está ACTIVO"
|
||||
},
|
||||
"buttons": {
|
||||
"closeKeyboard": "❌ Cerrar Teclado",
|
||||
@ -1773,5 +1855,57 @@
|
||||
"chooseClient": "Elige un Cliente para Inbound {{ .Inbound }}",
|
||||
"chooseInbound": "Elige un Inbound"
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"labelDelay": "Retardo",
|
||||
"labelDetail": "Detalle",
|
||||
"labelError": "Error",
|
||||
"labelIP": "IP",
|
||||
"labelOutbound": "Saliente",
|
||||
"labelReason": "Motivo",
|
||||
"labelSource": "Origen",
|
||||
"labelStatus": "Estado",
|
||||
"labelTime": "Hora",
|
||||
"labelUsername": "Usuario",
|
||||
"statusBanned": "BANNED",
|
||||
"statusCrashed": "BLOQUEADO",
|
||||
"statusDown": "CAÍDO",
|
||||
"statusFailed": "FALLIDO",
|
||||
"statusFull": "FULL",
|
||||
"statusHigh": "ALTA",
|
||||
"statusOffline": "OFFLINE",
|
||||
"statusOnline": "ONLINE",
|
||||
"statusRunning": "En ejecución",
|
||||
"statusSuccess": "CORRECTO",
|
||||
"statusUp": "ACTIVO",
|
||||
"statusXrayDown": "Xray DOWN",
|
||||
"statusXrayUp": "Xray UP",
|
||||
"subjectCPUHigh": "CPU alta",
|
||||
"subjectDiskFull": "Disk full",
|
||||
"subjectIPBanned": "IP banned: {{ .IP }}",
|
||||
"subjectLoginFailed": "Inicio de sesión fallido",
|
||||
"subjectLoginSuccess": "Inicio de sesión correcto",
|
||||
"subjectNodeOffline": "Node {{ .Node }} is OFFLINE",
|
||||
"subjectNodeOnline": "Node {{ .Node }} is ONLINE",
|
||||
"subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN",
|
||||
"subjectNodeXrayUp": "Node {{ .Node }} Xray is UP",
|
||||
"subjectOutboundDown": "El saliente {{ .Tag }} está CAÍDO",
|
||||
"subjectOutboundUp": "El saliente {{ .Tag }} está ACTIVO",
|
||||
"subjectXrayCrash": "Xray se ha BLOQUEADO",
|
||||
"subjectXrayUp": "Xray is UP",
|
||||
"titleCPUHigh": "CPU alta",
|
||||
"titleDiskFull": "Disk full",
|
||||
"titleIPBanned": "IP banned",
|
||||
"titleLoginFailed": "Inicio de sesión fallido",
|
||||
"titleLoginSuccess": "Inicio de sesión correcto",
|
||||
"titleNodeOffline": "Node OFFLINE",
|
||||
"titleNodeOnline": "Node ONLINE",
|
||||
"titleNodeXrayDown": "Node Xray DOWN",
|
||||
"titleNodeXrayUp": "Node Xray UP",
|
||||
"titleOutboundDown": "Saliente CAÍDO",
|
||||
"titleOutboundUp": "Saliente ACTIVO",
|
||||
"titleXrayCrash": "Xray se ha BLOQUEADO",
|
||||
"titleXrayUp": "Xray UP",
|
||||
"labelNode": "Nodo"
|
||||
}
|
||||
}
|
||||
@ -557,6 +557,15 @@
|
||||
"tcpCongestion": "تراکم TCP",
|
||||
"dialerProxy": "Dialer Proxy",
|
||||
"trustedXForwardedFor": "X-Forwarded-For مورد اعتماد",
|
||||
"trustedXForwardedForHint": "این هدر درخواست برای گرفتن IP واقعی کاربر مورد اعتماد قرار میگیرد (مثلاً CF-Connecting-IP پشت CDN کلودفلر). فقط روی ترنسپورتهای WebSocket، HTTPUpgrade و XHTTP اعمال میشود. برای نادیدهگرفتن هدرها خالی بگذارید.",
|
||||
"proxyProtocolHint": "پذیرش هدر PROXY protocol برای گرفتن IP واقعی کاربر از یک تونل/رله L4 بالادست (HAProxy، gost، nginx-stream، Xray dokodemo-door) یا Cloudflare Spectrum. بالادست باید PROXY protocol را ارسال کند. روی TCP، WebSocket، HTTPUpgrade و gRPC کار میکند؛ روی mKCP خیر.",
|
||||
"realClientIp": "IP واقعی کاربر",
|
||||
"realClientIpHint": "وقتی ترافیک از طریق CDN یا رله به این ورودی میرسد، بهجای ثبت آدرس واسط، IP واقعی کاربر گرفته میشود. یک پریست انتخاب کنید تا فیلدهای sockopt مربوطه پایین تکمیل شوند. این فیلدها هرگز در اشتراکها به کلاینتها ارسال نمیشوند.",
|
||||
"realClientIpPresetOff": "خاموش / مستقیم",
|
||||
"realClientIpPresetCloudflare": "Cloudflare CDN",
|
||||
"realClientIpPresetProxyProtocol": "رله L4 / Spectrum (PROXY)",
|
||||
"realClientIpTrustedHeaderTransportWarn": "Trusted X-Forwarded-For فقط روی WebSocket، HTTPUpgrade و XHTTP اعمال میشود. روی ترنسپورت فعلی این هدر نادیده گرفته میشود.",
|
||||
"realClientIpProxyProtocolTransportWarn": "PROXY protocol روی این ترنسپورت (mKCP) پشتیبانی نمیشود. از TCP/RAW، WebSocket، HTTPUpgrade، gRPC یا XHTTP استفاده کنید.",
|
||||
"addressPortStrategy": "استراتژی آدرس+پورت",
|
||||
"tryDelayMs": "تأخیر تلاش (ms)",
|
||||
"prioritizeIPv6": "اولویت IPv6",
|
||||
@ -1200,7 +1209,69 @@
|
||||
"userPassMustBeNotEmpty": "نامکاربری یا رمزعبور جدید خالیاست",
|
||||
"getOutboundTrafficError": "خطا در دریافت ترافیک خروجی",
|
||||
"resetOutboundTrafficError": "خطا در بازنشانی ترافیک خروجی"
|
||||
}
|
||||
},
|
||||
"emailNotifications": "اعلانها",
|
||||
"emailSettings": "ایمیل",
|
||||
"eventCPUHigh": "بالا بودن CPU (٪)",
|
||||
"eventGroupOutbound": "خروجی",
|
||||
"eventGroupSecurity": "امنیت",
|
||||
"eventGroupSystem": "سیستم",
|
||||
"eventGroupXray": "هسته Xray",
|
||||
"eventLoginAttempt": "تلاش برای ورود",
|
||||
"eventOutboundDown": "قطع",
|
||||
"eventOutboundUp": "وصل",
|
||||
"eventXrayCrash": "کرش",
|
||||
"requestFailed": "درخواست ناموفق بود",
|
||||
"smtpEnable": "فعالسازی اعلانهای ایمیلی",
|
||||
"smtpEnableDesc": "فعالسازی اعلانهای ایمیلی از طریق SMTP",
|
||||
"smtpEncryption": "رمزنگاری",
|
||||
"smtpEncryptionDesc": "روش رمزنگاری اتصال SMTP",
|
||||
"smtpEncryptionNone": "هیچکدام (متن ساده)",
|
||||
"smtpEncryptionStartTLS": "STARTTLS",
|
||||
"smtpEncryptionTLS": "TLS (ضمنی)",
|
||||
"smtpEventBusNotify": "اعلانهای رویداد ایمیلی",
|
||||
"smtpEventBusNotifyDesc": "انتخاب کنید کدام رویدادها اعلان ایمیلی را فعال میکنند",
|
||||
"smtpHost": "میزبان SMTP",
|
||||
"smtpHostDesc": "نام میزبان سرور SMTP (مثلاً smtp.gmail.com)",
|
||||
"smtpHostNotConfigured": "میزبان SMTP پیکربندی نشده است",
|
||||
"smtpNoRecipients": "هیچ گیرندهای پیکربندی نشده است",
|
||||
"smtpNotInitialized": "SMTP مقداردهی اولیه نشده است",
|
||||
"smtpPassword": "رمز عبور SMTP",
|
||||
"smtpPasswordDesc": "رمز عبور احراز هویت SMTP",
|
||||
"smtpPort": "پورت SMTP",
|
||||
"smtpPortDesc": "پورت سرور SMTP (پیشفرض: ۵۸۷)",
|
||||
"smtpSettings": "تنظیمات SMTP",
|
||||
"smtpStageAuth": "احراز هویت",
|
||||
"smtpStageConnect": "اتصال",
|
||||
"smtpStageSend": "ارسال",
|
||||
"smtpTestSuccess": "ایمیل آزمایشی با موفقیت ارسال شد",
|
||||
"smtpTo": "گیرندگان",
|
||||
"smtpToDesc": "آدرسهای ایمیل گیرندگان، جداشده با کاما",
|
||||
"smtpUsername": "نامکاربری SMTP",
|
||||
"smtpUsernameDesc": "نامکاربری احراز هویت SMTP",
|
||||
"telegramTokenConfigured": "پیکربندی شده؛ برای حفظ توکن فعلی خالی بگذارید.",
|
||||
"telegramTokenPlaceholder": "پیکربندی شده - برای جایگزینی، توکن جدید وارد کنید",
|
||||
"testSmtp": "ارسال ایمیل آزمایشی",
|
||||
"testTgBot": "ارسال پیام آزمایشی",
|
||||
"tgBotNotEnabled": "ربات تلگرام فعال نیست",
|
||||
"tgBotNotRunning": "ربات تلگرام در حال اجرا نیست",
|
||||
"tgEventBusNotify": "اعلانهای رویداد تلگرام",
|
||||
"tgEventBusNotifyDesc": "انتخاب کنید کدام رویدادها اعلان تلگرام را فعال میکنند",
|
||||
"tgTestFailed": "آزمایش تلگرام ناموفق بود",
|
||||
"tgTestSuccess": "پیام آزمایشی به تلگرام ارسال شد",
|
||||
"smtpErrorAuth": "احراز هویت ناموفق بود — نامکاربری و رمز عبور را بررسی کنید",
|
||||
"smtpErrorStarttls": "سرور به STARTTLS نیاز دارد — نوع رمزنگاری را تغییر دهید",
|
||||
"smtpErrorTls": "سرور به TLS نیاز دارد — نوع رمزنگاری را تغییر دهید",
|
||||
"smtpErrorRefused": "اتصال رد شد — میزبان و پورت را بررسی کنید",
|
||||
"smtpErrorTimeout": "مهلت اتصال به پایان رسید — میزبان در دسترس نیست",
|
||||
"smtpErrorRelay": "سرور ارسال از این آدرس را رد میکند",
|
||||
"smtpErrorEof": "اتصال توسط سرور بسته شد",
|
||||
"smtpErrorUnknown": "خطای SMTP: {{ .Error }}",
|
||||
"eventGroupNode": "نودها",
|
||||
"eventNodeDown": "قطع",
|
||||
"eventNodeUp": "وصل",
|
||||
"smtpPasswordConfigured": "پیکربندی شده؛ برای حفظ رمز عبور فعلی خالی بگذارید.",
|
||||
"smtpPasswordPlaceholder": "پیکربندی شده - برای جایگزینی، رمز عبور جدید وارد کنید"
|
||||
},
|
||||
"xray": {
|
||||
"title": "پیکربندی ایکسری",
|
||||
@ -1703,7 +1774,18 @@
|
||||
"AreYouSure": "مطمئنی؟ 🤔",
|
||||
"SuccessResetTraffic": "📧 ایمیل: {{ .ClientEmail }}\n🏁 نتیجه: ✅ موفقیتآمیز",
|
||||
"FailedResetTraffic": "📧 ایمیل: {{ .ClientEmail }}\n🏁 نتیجه: ❌ ناموفق \n\n🛠️ خطا: [ {{ .ErrorMessage }} ]",
|
||||
"FinishProcess": "🔚 فرآیند بازنشانی ترافیک برای همه مشتریان به پایان رسید."
|
||||
"FinishProcess": "🔚 فرآیند بازنشانی ترافیک برای همه مشتریان به پایان رسید.",
|
||||
"eventCPUHigh": "بالا بودن CPU",
|
||||
"eventCPUHighDetail": "CPU: {{ .Detail }}",
|
||||
"eventDelayDetail": "تأخیر: {{ .Delay }} میلیثانیه",
|
||||
"eventErrorDetail": "خطا: {{ .Error }}",
|
||||
"eventLoginFallback": "ورود ناموفق از {{ .Source }}",
|
||||
"eventOutboundDown": "خروجی {{ .Tag }} قطع است",
|
||||
"eventOutboundUp": "خروجی {{ .Tag }} وصل است",
|
||||
"eventXrayCrash": "Xray کرش کرد",
|
||||
"eventXrayCrashError": "خطا: {{ .Error }}",
|
||||
"eventNodeDown": "نود {{ .Name }} قطع است",
|
||||
"eventNodeUp": "نود {{ .Name }} وصل است"
|
||||
},
|
||||
"buttons": {
|
||||
"closeKeyboard": "❌ بستن کیبورد",
|
||||
@ -1773,5 +1855,57 @@
|
||||
"chooseClient": "یک مشتری برای ورودی {{ .Inbound }} انتخاب کنید",
|
||||
"chooseInbound": "یک ورودی انتخاب کنید"
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"labelDelay": "تأخیر",
|
||||
"labelDetail": "جزئیات",
|
||||
"labelError": "خطا",
|
||||
"labelIP": "IP",
|
||||
"labelOutbound": "خروجی",
|
||||
"labelReason": "دلیل",
|
||||
"labelSource": "مبدأ",
|
||||
"labelStatus": "وضعیت",
|
||||
"labelTime": "زمان",
|
||||
"labelUsername": "نامکاربری",
|
||||
"statusBanned": "BANNED",
|
||||
"statusCrashed": "کرش کرد",
|
||||
"statusDown": "قطع",
|
||||
"statusFailed": "ناموفق",
|
||||
"statusFull": "FULL",
|
||||
"statusHigh": "بالا",
|
||||
"statusOffline": "OFFLINE",
|
||||
"statusOnline": "ONLINE",
|
||||
"statusRunning": "در حال اجرا",
|
||||
"statusSuccess": "موفق",
|
||||
"statusUp": "وصل",
|
||||
"statusXrayDown": "Xray DOWN",
|
||||
"statusXrayUp": "Xray UP",
|
||||
"subjectCPUHigh": "بالا بودن CPU",
|
||||
"subjectDiskFull": "Disk full",
|
||||
"subjectIPBanned": "IP banned: {{ .IP }}",
|
||||
"subjectLoginFailed": "ورود ناموفق",
|
||||
"subjectLoginSuccess": "ورود موفق",
|
||||
"subjectNodeOffline": "Node {{ .Node }} is OFFLINE",
|
||||
"subjectNodeOnline": "Node {{ .Node }} is ONLINE",
|
||||
"subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN",
|
||||
"subjectNodeXrayUp": "Node {{ .Node }} Xray is UP",
|
||||
"subjectOutboundDown": "خروجی {{ .Tag }} قطع است",
|
||||
"subjectOutboundUp": "خروجی {{ .Tag }} وصل است",
|
||||
"subjectXrayCrash": "Xray کرش کرد",
|
||||
"subjectXrayUp": "Xray is UP",
|
||||
"titleCPUHigh": "بالا بودن CPU",
|
||||
"titleDiskFull": "Disk full",
|
||||
"titleIPBanned": "IP banned",
|
||||
"titleLoginFailed": "ورود ناموفق",
|
||||
"titleLoginSuccess": "ورود موفق",
|
||||
"titleNodeOffline": "Node OFFLINE",
|
||||
"titleNodeOnline": "Node ONLINE",
|
||||
"titleNodeXrayDown": "Node Xray DOWN",
|
||||
"titleNodeXrayUp": "Node Xray UP",
|
||||
"titleOutboundDown": "خروجی قطع شد",
|
||||
"titleOutboundUp": "خروجی وصل شد",
|
||||
"titleXrayCrash": "Xray کرش کرد",
|
||||
"titleXrayUp": "Xray UP",
|
||||
"labelNode": "نود"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -557,6 +557,15 @@
|
||||
"tcpCongestion": "TCP Congestion",
|
||||
"dialerProxy": "Dialer Proxy",
|
||||
"trustedXForwardedFor": "X-Forwarded-For tepercaya",
|
||||
"trustedXForwardedForHint": "Percayai header permintaan ini untuk IP klien asli (mis. CF-Connecting-IP di belakang CDN Cloudflare). Hanya berlaku pada transport WebSocket, HTTPUpgrade, dan XHTTP. Kosongkan untuk mengabaikan header yang diteruskan.",
|
||||
"proxyProtocolHint": "Terima header PROXY protocol untuk mengetahui IP klien asli dari tunnel/relay L4 di hulu (HAProxy, gost, nginx-stream, Xray dokodemo-door) atau Cloudflare Spectrum. Hulu HARUS mengirim PROXY protocol. Berfungsi pada TCP, WebSocket, HTTPUpgrade, dan gRPC; tidak pada mKCP.",
|
||||
"realClientIp": "IP klien asli",
|
||||
"realClientIpHint": "Tangkap IP asli pengunjung saat lalu lintas mencapai inbound ini melalui CDN atau relay, alih-alih mencatat alamat perantara. Pilih preset untuk mengisi kolom sockopt terkait di bawah. Kolom ini tidak pernah dikirim ke klien dalam langganan.",
|
||||
"realClientIpPresetOff": "Mati / langsung",
|
||||
"realClientIpPresetCloudflare": "Cloudflare CDN",
|
||||
"realClientIpPresetProxyProtocol": "Relay L4 / Spectrum (PROXY)",
|
||||
"realClientIpTrustedHeaderTransportWarn": "Trusted X-Forwarded-For hanya berlaku pada WebSocket, HTTPUpgrade, dan XHTTP. Pada transport saat ini header ini diabaikan.",
|
||||
"realClientIpProxyProtocolTransportWarn": "PROXY protocol tidak didukung pada transport ini (mKCP). Gunakan TCP/RAW, WebSocket, HTTPUpgrade, gRPC, atau XHTTP.",
|
||||
"addressPortStrategy": "Strategi alamat+port",
|
||||
"tryDelayMs": "Penundaan percobaan (ms)",
|
||||
"prioritizeIPv6": "Prioritaskan IPv6",
|
||||
@ -1200,7 +1209,69 @@
|
||||
"userPassMustBeNotEmpty": "Username dan password baru tidak boleh kosong",
|
||||
"getOutboundTrafficError": "Gagal mendapatkan lalu lintas keluar",
|
||||
"resetOutboundTrafficError": "Gagal mereset lalu lintas keluar"
|
||||
}
|
||||
},
|
||||
"emailNotifications": "Notifikasi",
|
||||
"emailSettings": "Email",
|
||||
"eventCPUHigh": "CPU tinggi (%)",
|
||||
"eventGroupOutbound": "Outbound",
|
||||
"eventGroupSecurity": "Keamanan",
|
||||
"eventGroupSystem": "Sistem",
|
||||
"eventGroupXray": "Xray Core",
|
||||
"eventLoginAttempt": "Percobaan masuk",
|
||||
"eventOutboundDown": "Mati",
|
||||
"eventOutboundUp": "Aktif",
|
||||
"eventXrayCrash": "Crash",
|
||||
"requestFailed": "Permintaan gagal",
|
||||
"smtpEnable": "Aktifkan Notifikasi Email",
|
||||
"smtpEnableDesc": "Aktifkan notifikasi email melalui SMTP",
|
||||
"smtpEncryption": "Enkripsi",
|
||||
"smtpEncryptionDesc": "Metode enkripsi koneksi SMTP",
|
||||
"smtpEncryptionNone": "Tidak ada (teks biasa)",
|
||||
"smtpEncryptionStartTLS": "STARTTLS",
|
||||
"smtpEncryptionTLS": "TLS (implisit)",
|
||||
"smtpEventBusNotify": "Notifikasi Peristiwa Email",
|
||||
"smtpEventBusNotifyDesc": "Pilih peristiwa yang memicu notifikasi email",
|
||||
"smtpHost": "Host SMTP",
|
||||
"smtpHostDesc": "Nama host server SMTP (mis. smtp.gmail.com)",
|
||||
"smtpHostNotConfigured": "Host SMTP belum dikonfigurasi",
|
||||
"smtpNoRecipients": "Tidak ada penerima yang dikonfigurasi",
|
||||
"smtpNotInitialized": "SMTP belum diinisialisasi",
|
||||
"smtpPassword": "Kata Sandi SMTP",
|
||||
"smtpPasswordDesc": "Kata sandi autentikasi SMTP",
|
||||
"smtpPort": "Port SMTP",
|
||||
"smtpPortDesc": "Port server SMTP (bawaan: 587)",
|
||||
"smtpSettings": "Pengaturan SMTP",
|
||||
"smtpStageAuth": "Autentikasi",
|
||||
"smtpStageConnect": "Koneksi",
|
||||
"smtpStageSend": "Kirim",
|
||||
"smtpTestSuccess": "Email uji berhasil dikirim",
|
||||
"smtpTo": "Penerima",
|
||||
"smtpToDesc": "Alamat email penerima dipisahkan dengan koma",
|
||||
"smtpUsername": "Nama Pengguna SMTP",
|
||||
"smtpUsernameDesc": "Nama pengguna autentikasi SMTP",
|
||||
"telegramTokenConfigured": "Terkonfigurasi; kosongkan untuk mempertahankan token saat ini.",
|
||||
"telegramTokenPlaceholder": "Terkonfigurasi - masukkan token baru untuk mengganti",
|
||||
"testSmtp": "Kirim Email Uji",
|
||||
"testTgBot": "Kirim Pesan Uji",
|
||||
"tgBotNotEnabled": "Bot Telegram tidak aktif",
|
||||
"tgBotNotRunning": "Bot Telegram tidak berjalan",
|
||||
"tgEventBusNotify": "Notifikasi Peristiwa Telegram",
|
||||
"tgEventBusNotifyDesc": "Pilih peristiwa yang memicu notifikasi Telegram",
|
||||
"tgTestFailed": "Uji Telegram gagal",
|
||||
"tgTestSuccess": "Pesan uji terkirim ke Telegram",
|
||||
"smtpErrorAuth": "Autentikasi gagal — periksa nama pengguna dan kata sandi",
|
||||
"smtpErrorStarttls": "Server memerlukan STARTTLS — ubah jenis enkripsi",
|
||||
"smtpErrorTls": "Server memerlukan TLS — ubah jenis enkripsi",
|
||||
"smtpErrorRefused": "Koneksi ditolak — periksa host dan port",
|
||||
"smtpErrorTimeout": "Koneksi waktu habis — host tidak dapat dijangkau",
|
||||
"smtpErrorRelay": "Server menolak pengiriman dari alamat ini",
|
||||
"smtpErrorEof": "Koneksi ditutup oleh server",
|
||||
"smtpErrorUnknown": "Kesalahan SMTP: {{ .Error }}",
|
||||
"eventGroupNode": "Node",
|
||||
"eventNodeDown": "Mati",
|
||||
"eventNodeUp": "Aktif",
|
||||
"smtpPasswordConfigured": "Terkonfigurasi; kosongkan untuk mempertahankan kata sandi saat ini.",
|
||||
"smtpPasswordPlaceholder": "Terkonfigurasi - masukkan kata sandi baru untuk mengganti"
|
||||
},
|
||||
"xray": {
|
||||
"title": "Konfigurasi Xray",
|
||||
@ -1703,7 +1774,18 @@
|
||||
"AreYouSure": "Apakah kamu yakin? 🤔",
|
||||
"SuccessResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Hasil: ✅ Berhasil",
|
||||
"FailedResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Hasil: ❌ Gagal \n\n🛠️ Kesalahan: [ {{ .ErrorMessage }} ]",
|
||||
"FinishProcess": "🔚 Proses reset traffic selesai untuk semua klien."
|
||||
"FinishProcess": "🔚 Proses reset traffic selesai untuk semua klien.",
|
||||
"eventCPUHigh": "CPU tinggi",
|
||||
"eventCPUHighDetail": "CPU: {{ .Detail }}",
|
||||
"eventDelayDetail": "Penundaan: {{ .Delay }}ms",
|
||||
"eventErrorDetail": "Kesalahan: {{ .Error }}",
|
||||
"eventLoginFallback": "Gagal masuk dari {{ .Source }}",
|
||||
"eventOutboundDown": "Outbound {{ .Tag }} MATI",
|
||||
"eventOutboundUp": "Outbound {{ .Tag }} AKTIF",
|
||||
"eventXrayCrash": "Xray CRASH",
|
||||
"eventXrayCrashError": "Kesalahan: {{ .Error }}",
|
||||
"eventNodeDown": "Node {{ .Name }} MATI",
|
||||
"eventNodeUp": "Node {{ .Name }} AKTIF"
|
||||
},
|
||||
"buttons": {
|
||||
"closeKeyboard": "❌ Tutup Papan Ketik",
|
||||
@ -1773,5 +1855,57 @@
|
||||
"chooseClient": "Pilih Klien untuk Inbound {{ .Inbound }}",
|
||||
"chooseInbound": "Pilih Inbound"
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"labelDelay": "Penundaan",
|
||||
"labelDetail": "Detail",
|
||||
"labelError": "Kesalahan",
|
||||
"labelIP": "IP",
|
||||
"labelOutbound": "Outbound",
|
||||
"labelReason": "Alasan",
|
||||
"labelSource": "Sumber",
|
||||
"labelStatus": "Status",
|
||||
"labelTime": "Waktu",
|
||||
"labelUsername": "Nama Pengguna",
|
||||
"statusBanned": "BANNED",
|
||||
"statusCrashed": "CRASH",
|
||||
"statusDown": "MATI",
|
||||
"statusFailed": "GAGAL",
|
||||
"statusFull": "FULL",
|
||||
"statusHigh": "TINGGI",
|
||||
"statusOffline": "OFFLINE",
|
||||
"statusOnline": "ONLINE",
|
||||
"statusRunning": "Berjalan",
|
||||
"statusSuccess": "BERHASIL",
|
||||
"statusUp": "AKTIF",
|
||||
"statusXrayDown": "Xray DOWN",
|
||||
"statusXrayUp": "Xray UP",
|
||||
"subjectCPUHigh": "CPU tinggi",
|
||||
"subjectDiskFull": "Disk full",
|
||||
"subjectIPBanned": "IP banned: {{ .IP }}",
|
||||
"subjectLoginFailed": "Gagal masuk",
|
||||
"subjectLoginSuccess": "Berhasil masuk",
|
||||
"subjectNodeOffline": "Node {{ .Node }} is OFFLINE",
|
||||
"subjectNodeOnline": "Node {{ .Node }} is ONLINE",
|
||||
"subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN",
|
||||
"subjectNodeXrayUp": "Node {{ .Node }} Xray is UP",
|
||||
"subjectOutboundDown": "Outbound {{ .Tag }} MATI",
|
||||
"subjectOutboundUp": "Outbound {{ .Tag }} AKTIF",
|
||||
"subjectXrayCrash": "Xray CRASH",
|
||||
"subjectXrayUp": "Xray is UP",
|
||||
"titleCPUHigh": "CPU tinggi",
|
||||
"titleDiskFull": "Disk full",
|
||||
"titleIPBanned": "IP banned",
|
||||
"titleLoginFailed": "Gagal masuk",
|
||||
"titleLoginSuccess": "Berhasil masuk",
|
||||
"titleNodeOffline": "Node OFFLINE",
|
||||
"titleNodeOnline": "Node ONLINE",
|
||||
"titleNodeXrayDown": "Node Xray DOWN",
|
||||
"titleNodeXrayUp": "Node Xray UP",
|
||||
"titleOutboundDown": "Outbound MATI",
|
||||
"titleOutboundUp": "Outbound AKTIF",
|
||||
"titleXrayCrash": "Xray CRASH",
|
||||
"titleXrayUp": "Xray UP",
|
||||
"labelNode": "Node"
|
||||
}
|
||||
}
|
||||
@ -557,6 +557,15 @@
|
||||
"tcpCongestion": "TCP Congestion",
|
||||
"dialerProxy": "Dialer Proxy",
|
||||
"trustedXForwardedFor": "信頼できる X-Forwarded-For",
|
||||
"trustedXForwardedForHint": "実際のクライアント IP を取得するためにこのリクエストヘッダーを信頼します(例: Cloudflare CDN の背後の CF-Connecting-IP)。WebSocket、HTTPUpgrade、XHTTP トランスポートでのみ有効です。空欄にすると転送ヘッダーを無視します。",
|
||||
"proxyProtocolHint": "PROXY protocol ヘッダーを受け入れ、上流の L4 トンネル/リレー(HAProxy、gost、nginx-stream、Xray dokodemo-door)または Cloudflare Spectrum から実際のクライアント IP を取得します。上流は必ず PROXY protocol を送信する必要があります。TCP、WebSocket、HTTPUpgrade、gRPC で動作します。mKCP では動作しません。",
|
||||
"realClientIp": "実際のクライアント IP",
|
||||
"realClientIpHint": "トラフィックが CDN やリレーを経由してこのインバウンドに到達したときに、中継ノードのアドレスではなく訪問者の実際の IP を取得します。プリセットを選ぶと、下の対応する sockopt フィールドが自動入力されます。これらのフィールドはサブスクリプションでクライアントに送信されることはありません。",
|
||||
"realClientIpPresetOff": "オフ / 直接",
|
||||
"realClientIpPresetCloudflare": "Cloudflare CDN",
|
||||
"realClientIpPresetProxyProtocol": "L4 リレー / Spectrum (PROXY)",
|
||||
"realClientIpTrustedHeaderTransportWarn": "Trusted X-Forwarded-For は WebSocket、HTTPUpgrade、XHTTP でのみ有効です。現在のトランスポートではこのヘッダーは無視されます。",
|
||||
"realClientIpProxyProtocolTransportWarn": "PROXY protocol はこのトランスポート(mKCP)ではサポートされていません。TCP/RAW、WebSocket、HTTPUpgrade、gRPC、または XHTTP を使用してください。",
|
||||
"addressPortStrategy": "アドレス+ポート戦略",
|
||||
"tryDelayMs": "試行遅延 (ms)",
|
||||
"prioritizeIPv6": "IPv6 優先",
|
||||
@ -1200,7 +1209,69 @@
|
||||
"userPassMustBeNotEmpty": "新しいユーザー名と新しいパスワードは空にできません",
|
||||
"getOutboundTrafficError": "送信トラフィックの取得エラー",
|
||||
"resetOutboundTrafficError": "送信トラフィックのリセットエラー"
|
||||
}
|
||||
},
|
||||
"emailNotifications": "通知",
|
||||
"emailSettings": "メール",
|
||||
"eventCPUHigh": "CPU高負荷(%)",
|
||||
"eventGroupOutbound": "アウトバウンド",
|
||||
"eventGroupSecurity": "セキュリティ",
|
||||
"eventGroupSystem": "システム",
|
||||
"eventGroupXray": "Xrayコア",
|
||||
"eventLoginAttempt": "ログイン試行",
|
||||
"eventOutboundDown": "ダウン",
|
||||
"eventOutboundUp": "アップ",
|
||||
"eventXrayCrash": "クラッシュ",
|
||||
"requestFailed": "リクエストに失敗しました",
|
||||
"smtpEnable": "メール通知を有効化",
|
||||
"smtpEnableDesc": "SMTP経由のメール通知を有効にします",
|
||||
"smtpEncryption": "暗号化",
|
||||
"smtpEncryptionDesc": "SMTP接続の暗号化方式",
|
||||
"smtpEncryptionNone": "なし(平文)",
|
||||
"smtpEncryptionStartTLS": "STARTTLS",
|
||||
"smtpEncryptionTLS": "TLS(暗黙的)",
|
||||
"smtpEventBusNotify": "メールイベント通知",
|
||||
"smtpEventBusNotifyDesc": "メール通知をトリガーするイベントを選択してください",
|
||||
"smtpHost": "SMTPホスト",
|
||||
"smtpHostDesc": "SMTPサーバーのホスト名(例: smtp.gmail.com)",
|
||||
"smtpHostNotConfigured": "SMTPホストが設定されていません",
|
||||
"smtpNoRecipients": "受信者が設定されていません",
|
||||
"smtpNotInitialized": "SMTPが初期化されていません",
|
||||
"smtpPassword": "SMTPパスワード",
|
||||
"smtpPasswordDesc": "SMTP認証用のパスワード",
|
||||
"smtpPort": "SMTPポート",
|
||||
"smtpPortDesc": "SMTPサーバーのポート(既定値: 587)",
|
||||
"smtpSettings": "SMTP設定",
|
||||
"smtpStageAuth": "認証",
|
||||
"smtpStageConnect": "接続",
|
||||
"smtpStageSend": "送信",
|
||||
"smtpTestSuccess": "テストメールを正常に送信しました",
|
||||
"smtpTo": "受信者",
|
||||
"smtpToDesc": "受信者のメールアドレス(カンマ区切り)",
|
||||
"smtpUsername": "SMTPユーザー名",
|
||||
"smtpUsernameDesc": "SMTP認証用のユーザー名",
|
||||
"telegramTokenConfigured": "設定済み。現在のトークンを維持する場合は空欄のままにしてください。",
|
||||
"telegramTokenPlaceholder": "設定済み - 置き換えるには新しいトークンを入力してください",
|
||||
"testSmtp": "テストメールを送信",
|
||||
"testTgBot": "テストメッセージを送信",
|
||||
"tgBotNotEnabled": "Telegramボットが有効になっていません",
|
||||
"tgBotNotRunning": "Telegramボットが実行されていません",
|
||||
"tgEventBusNotify": "Telegramイベント通知",
|
||||
"tgEventBusNotifyDesc": "Telegram通知をトリガーするイベントを選択してください",
|
||||
"tgTestFailed": "Telegramのテストに失敗しました",
|
||||
"tgTestSuccess": "Telegramにテストメッセージを送信しました",
|
||||
"smtpErrorAuth": "認証に失敗しました — ユーザー名とパスワードを確認してください",
|
||||
"smtpErrorStarttls": "サーバーはSTARTTLSを要求しています — 暗号化方式を変更してください",
|
||||
"smtpErrorTls": "サーバーはTLSを要求しています — 暗号化方式を変更してください",
|
||||
"smtpErrorRefused": "接続が拒否されました — ホストとポートを確認してください",
|
||||
"smtpErrorTimeout": "接続がタイムアウトしました — ホストに到達できません",
|
||||
"smtpErrorRelay": "サーバーはこのアドレスからの送信を拒否しています",
|
||||
"smtpErrorEof": "サーバーによって接続が閉じられました",
|
||||
"smtpErrorUnknown": "SMTPエラー: {{ .Error }}",
|
||||
"eventGroupNode": "ノード",
|
||||
"eventNodeDown": "ダウン",
|
||||
"eventNodeUp": "アップ",
|
||||
"smtpPasswordConfigured": "設定済み。現在のパスワードを維持する場合は空欄のままにしてください。",
|
||||
"smtpPasswordPlaceholder": "設定済み - 置き換えるには新しいパスワードを入力してください"
|
||||
},
|
||||
"xray": {
|
||||
"title": "Xray 設定",
|
||||
@ -1703,7 +1774,18 @@
|
||||
"AreYouSure": "本当にいいですか?🤔",
|
||||
"SuccessResetTraffic": "📧 メール: {{ .ClientEmail }}\n🏁 結果: ✅ 成功",
|
||||
"FailedResetTraffic": "📧 メール: {{ .ClientEmail }}\n🏁 結果: ❌ 失敗 \n\n🛠️ エラー: [ {{ .ErrorMessage }} ]",
|
||||
"FinishProcess": "🔚 すべてのクライアントのトラフィックリセットが完了しました。"
|
||||
"FinishProcess": "🔚 すべてのクライアントのトラフィックリセットが完了しました。",
|
||||
"eventCPUHigh": "CPU高負荷",
|
||||
"eventCPUHighDetail": "CPU: {{ .Detail }}",
|
||||
"eventDelayDetail": "遅延: {{ .Delay }}ms",
|
||||
"eventErrorDetail": "エラー: {{ .Error }}",
|
||||
"eventLoginFallback": "{{ .Source }} からのログインに失敗しました",
|
||||
"eventOutboundDown": "アウトバウンド {{ .Tag }} がダウンしています",
|
||||
"eventOutboundUp": "アウトバウンド {{ .Tag }} が復旧しました",
|
||||
"eventXrayCrash": "Xrayがクラッシュしました",
|
||||
"eventXrayCrashError": "エラー: {{ .Error }}",
|
||||
"eventNodeDown": "ノード {{ .Name }} がダウンしています",
|
||||
"eventNodeUp": "ノード {{ .Name }} が復旧しました"
|
||||
},
|
||||
"buttons": {
|
||||
"closeKeyboard": "❌ キーボードを閉じる",
|
||||
@ -1773,5 +1855,57 @@
|
||||
"chooseClient": "インバウンド {{ .Inbound }} のクライアントを選択",
|
||||
"chooseInbound": "インバウンドを選択"
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"labelDelay": "遅延",
|
||||
"labelDetail": "詳細",
|
||||
"labelError": "エラー",
|
||||
"labelIP": "IP",
|
||||
"labelOutbound": "アウトバウンド",
|
||||
"labelReason": "理由",
|
||||
"labelSource": "送信元",
|
||||
"labelStatus": "ステータス",
|
||||
"labelTime": "時刻",
|
||||
"labelUsername": "ユーザー名",
|
||||
"statusBanned": "BANNED",
|
||||
"statusCrashed": "クラッシュ",
|
||||
"statusDown": "ダウン",
|
||||
"statusFailed": "失敗",
|
||||
"statusFull": "FULL",
|
||||
"statusHigh": "高負荷",
|
||||
"statusOffline": "OFFLINE",
|
||||
"statusOnline": "ONLINE",
|
||||
"statusRunning": "実行中",
|
||||
"statusSuccess": "成功",
|
||||
"statusUp": "アップ",
|
||||
"statusXrayDown": "Xray DOWN",
|
||||
"statusXrayUp": "Xray UP",
|
||||
"subjectCPUHigh": "CPU高負荷",
|
||||
"subjectDiskFull": "Disk full",
|
||||
"subjectIPBanned": "IP banned: {{ .IP }}",
|
||||
"subjectLoginFailed": "ログイン失敗",
|
||||
"subjectLoginSuccess": "ログイン成功",
|
||||
"subjectNodeOffline": "Node {{ .Node }} is OFFLINE",
|
||||
"subjectNodeOnline": "Node {{ .Node }} is ONLINE",
|
||||
"subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN",
|
||||
"subjectNodeXrayUp": "Node {{ .Node }} Xray is UP",
|
||||
"subjectOutboundDown": "アウトバウンド {{ .Tag }} がダウンしています",
|
||||
"subjectOutboundUp": "アウトバウンド {{ .Tag }} が復旧しました",
|
||||
"subjectXrayCrash": "Xrayがクラッシュしました",
|
||||
"subjectXrayUp": "Xray is UP",
|
||||
"titleCPUHigh": "CPU高負荷",
|
||||
"titleDiskFull": "Disk full",
|
||||
"titleIPBanned": "IP banned",
|
||||
"titleLoginFailed": "ログイン失敗",
|
||||
"titleLoginSuccess": "ログイン成功",
|
||||
"titleNodeOffline": "Node OFFLINE",
|
||||
"titleNodeOnline": "Node ONLINE",
|
||||
"titleNodeXrayDown": "Node Xray DOWN",
|
||||
"titleNodeXrayUp": "Node Xray UP",
|
||||
"titleOutboundDown": "アウトバウンド ダウン",
|
||||
"titleOutboundUp": "アウトバウンド 復旧",
|
||||
"titleXrayCrash": "Xrayがクラッシュしました",
|
||||
"titleXrayUp": "Xray UP",
|
||||
"labelNode": "ノード"
|
||||
}
|
||||
}
|
||||
@ -557,6 +557,15 @@
|
||||
"tcpCongestion": "TCP Congestion",
|
||||
"dialerProxy": "Dialer Proxy",
|
||||
"trustedXForwardedFor": "X-Forwarded-For confiável",
|
||||
"trustedXForwardedForHint": "Confie neste cabeçalho de requisição para obter o IP real do cliente (ex.: CF-Connecting-IP atrás do CDN da Cloudflare). Válido apenas nos transportes WebSocket, HTTPUpgrade e XHTTP. Deixe vazio para ignorar cabeçalhos encaminhados.",
|
||||
"proxyProtocolHint": "Aceite o cabeçalho PROXY protocol para obter o IP real do cliente a partir de um túnel/relay L4 upstream (HAProxy, gost, nginx-stream, Xray dokodemo-door) ou Cloudflare Spectrum. O upstream DEVE enviar PROXY protocol. Funciona em TCP, WebSocket, HTTPUpgrade e gRPC; não em mKCP.",
|
||||
"realClientIp": "IP real do cliente",
|
||||
"realClientIpHint": "Capture o IP real do visitante quando o tráfego chega a este inbound através de um CDN ou relay, em vez de registrar o endereço do intermediário. Escolha uma predefinição para preencher os campos sockopt correspondentes abaixo. Esses campos nunca são enviados aos clientes nas assinaturas.",
|
||||
"realClientIpPresetOff": "Desligado / direto",
|
||||
"realClientIpPresetCloudflare": "Cloudflare CDN",
|
||||
"realClientIpPresetProxyProtocol": "Relay L4 / Spectrum (PROXY)",
|
||||
"realClientIpTrustedHeaderTransportWarn": "Trusted X-Forwarded-For é válido apenas em WebSocket, HTTPUpgrade e XHTTP. No transporte atual este cabeçalho é ignorado.",
|
||||
"realClientIpProxyProtocolTransportWarn": "PROXY protocol não é suportado neste transporte (mKCP). Use TCP/RAW, WebSocket, HTTPUpgrade, gRPC ou XHTTP.",
|
||||
"addressPortStrategy": "Estratégia endereço+porta",
|
||||
"tryDelayMs": "Atraso de tentativa (ms)",
|
||||
"prioritizeIPv6": "Priorizar IPv6",
|
||||
@ -1200,7 +1209,69 @@
|
||||
"userPassMustBeNotEmpty": "O novo nome de usuário e senha não podem estar vazios",
|
||||
"getOutboundTrafficError": "Erro ao obter tráfego de saída",
|
||||
"resetOutboundTrafficError": "Erro ao redefinir tráfego de saída"
|
||||
}
|
||||
},
|
||||
"emailNotifications": "Notificações",
|
||||
"emailSettings": "E-mail",
|
||||
"eventCPUHigh": "CPU alta (%)",
|
||||
"eventGroupOutbound": "Outbound",
|
||||
"eventGroupSecurity": "Segurança",
|
||||
"eventGroupSystem": "Sistema",
|
||||
"eventGroupXray": "Núcleo Xray",
|
||||
"eventLoginAttempt": "Tentativa de login",
|
||||
"eventOutboundDown": "Inativo",
|
||||
"eventOutboundUp": "Ativo",
|
||||
"eventXrayCrash": "Falha",
|
||||
"requestFailed": "Falha na requisição",
|
||||
"smtpEnable": "Ativar notificações por e-mail",
|
||||
"smtpEnableDesc": "Ativar notificações por e-mail via SMTP",
|
||||
"smtpEncryption": "Criptografia",
|
||||
"smtpEncryptionDesc": "Método de criptografia da conexão SMTP",
|
||||
"smtpEncryptionNone": "Nenhuma (texto puro)",
|
||||
"smtpEncryptionStartTLS": "STARTTLS",
|
||||
"smtpEncryptionTLS": "TLS (implícito)",
|
||||
"smtpEventBusNotify": "Notificações de eventos por e-mail",
|
||||
"smtpEventBusNotifyDesc": "Selecione quais eventos disparam notificações por e-mail",
|
||||
"smtpHost": "Servidor SMTP",
|
||||
"smtpHostDesc": "Nome do host do servidor SMTP (ex.: smtp.gmail.com)",
|
||||
"smtpHostNotConfigured": "Servidor SMTP não configurado",
|
||||
"smtpNoRecipients": "Nenhum destinatário configurado",
|
||||
"smtpNotInitialized": "SMTP não inicializado",
|
||||
"smtpPassword": "Senha SMTP",
|
||||
"smtpPasswordDesc": "Senha para autenticação SMTP",
|
||||
"smtpPort": "Porta SMTP",
|
||||
"smtpPortDesc": "Porta do servidor SMTP (padrão: 587)",
|
||||
"smtpSettings": "Configurações SMTP",
|
||||
"smtpStageAuth": "Autenticação",
|
||||
"smtpStageConnect": "Conexão",
|
||||
"smtpStageSend": "Envio",
|
||||
"smtpTestSuccess": "E-mail de teste enviado com sucesso",
|
||||
"smtpTo": "Destinatários",
|
||||
"smtpToDesc": "Endereços de e-mail dos destinatários separados por vírgula",
|
||||
"smtpUsername": "Usuário SMTP",
|
||||
"smtpUsernameDesc": "Nome de usuário para autenticação SMTP",
|
||||
"telegramTokenConfigured": "Configurado; deixe em branco para manter o token atual.",
|
||||
"telegramTokenPlaceholder": "Configurado - insira um novo token para substituir",
|
||||
"testSmtp": "Enviar e-mail de teste",
|
||||
"testTgBot": "Enviar mensagem de teste",
|
||||
"tgBotNotEnabled": "O bot do Telegram não está ativado",
|
||||
"tgBotNotRunning": "O bot do Telegram não está em execução",
|
||||
"tgEventBusNotify": "Notificações de eventos no Telegram",
|
||||
"tgEventBusNotifyDesc": "Selecione quais eventos disparam notificações no Telegram",
|
||||
"tgTestFailed": "Falha no teste do Telegram",
|
||||
"tgTestSuccess": "Mensagem de teste enviada ao Telegram",
|
||||
"smtpErrorAuth": "Falha na autenticação — verifique o nome de usuário e a senha",
|
||||
"smtpErrorStarttls": "O servidor requer STARTTLS — altere o tipo de criptografia",
|
||||
"smtpErrorTls": "O servidor requer TLS — altere o tipo de criptografia",
|
||||
"smtpErrorRefused": "Conexão recusada — verifique o host e a porta",
|
||||
"smtpErrorTimeout": "Tempo de conexão esgotado — host inacessível",
|
||||
"smtpErrorRelay": "O servidor rejeita o envio a partir deste endereço",
|
||||
"smtpErrorEof": "Conexão encerrada pelo servidor",
|
||||
"smtpErrorUnknown": "Erro de SMTP: {{ .Error }}",
|
||||
"eventGroupNode": "Nós",
|
||||
"eventNodeDown": "Inativo",
|
||||
"eventNodeUp": "Ativo",
|
||||
"smtpPasswordConfigured": "Configurada; deixe em branco para manter a senha atual.",
|
||||
"smtpPasswordPlaceholder": "Configurada - insira uma nova senha para substituir"
|
||||
},
|
||||
"xray": {
|
||||
"title": "Configurações Xray",
|
||||
@ -1703,7 +1774,18 @@
|
||||
"AreYouSure": "Você tem certeza? 🤔",
|
||||
"SuccessResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Resultado: ✅ Sucesso",
|
||||
"FailedResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Resultado: ❌ Falhou \n\n🛠️ Erro: [ {{ .ErrorMessage }} ]",
|
||||
"FinishProcess": "🔚 Processo de redefinição de tráfego concluído para todos os clientes."
|
||||
"FinishProcess": "🔚 Processo de redefinição de tráfego concluído para todos os clientes.",
|
||||
"eventCPUHigh": "CPU alta",
|
||||
"eventCPUHighDetail": "CPU: {{ .Detail }}",
|
||||
"eventDelayDetail": "Latência: {{ .Delay }}ms",
|
||||
"eventErrorDetail": "Erro: {{ .Error }}",
|
||||
"eventLoginFallback": "Falha de login a partir de {{ .Source }}",
|
||||
"eventOutboundDown": "O outbound {{ .Tag }} está INATIVO",
|
||||
"eventOutboundUp": "O outbound {{ .Tag }} está ATIVO",
|
||||
"eventXrayCrash": "O Xray FALHOU",
|
||||
"eventXrayCrashError": "Erro: {{ .Error }}",
|
||||
"eventNodeDown": "O nó {{ .Name }} está INATIVO",
|
||||
"eventNodeUp": "O nó {{ .Name }} está ATIVO"
|
||||
},
|
||||
"buttons": {
|
||||
"closeKeyboard": "❌ Fechar teclado",
|
||||
@ -1773,5 +1855,57 @@
|
||||
"chooseClient": "Escolha um cliente para Inbound {{ .Inbound }}",
|
||||
"chooseInbound": "Escolha um Inbound"
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"labelDelay": "Latência",
|
||||
"labelDetail": "Detalhe",
|
||||
"labelError": "Erro",
|
||||
"labelIP": "IP",
|
||||
"labelOutbound": "Outbound",
|
||||
"labelReason": "Motivo",
|
||||
"labelSource": "Origem",
|
||||
"labelStatus": "Status",
|
||||
"labelTime": "Horário",
|
||||
"labelUsername": "Nome de usuário",
|
||||
"statusBanned": "BANNED",
|
||||
"statusCrashed": "FALHOU",
|
||||
"statusDown": "INATIVO",
|
||||
"statusFailed": "FALHOU",
|
||||
"statusFull": "FULL",
|
||||
"statusHigh": "ALTA",
|
||||
"statusOffline": "OFFLINE",
|
||||
"statusOnline": "ONLINE",
|
||||
"statusRunning": "Em execução",
|
||||
"statusSuccess": "SUCESSO",
|
||||
"statusUp": "ATIVO",
|
||||
"statusXrayDown": "Xray DOWN",
|
||||
"statusXrayUp": "Xray UP",
|
||||
"subjectCPUHigh": "CPU alta",
|
||||
"subjectDiskFull": "Disk full",
|
||||
"subjectIPBanned": "IP banned: {{ .IP }}",
|
||||
"subjectLoginFailed": "Falha de login",
|
||||
"subjectLoginSuccess": "Login bem-sucedido",
|
||||
"subjectNodeOffline": "Node {{ .Node }} is OFFLINE",
|
||||
"subjectNodeOnline": "Node {{ .Node }} is ONLINE",
|
||||
"subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN",
|
||||
"subjectNodeXrayUp": "Node {{ .Node }} Xray is UP",
|
||||
"subjectOutboundDown": "O outbound {{ .Tag }} está INATIVO",
|
||||
"subjectOutboundUp": "O outbound {{ .Tag }} está ATIVO",
|
||||
"subjectXrayCrash": "O Xray FALHOU",
|
||||
"subjectXrayUp": "Xray is UP",
|
||||
"titleCPUHigh": "CPU alta",
|
||||
"titleDiskFull": "Disk full",
|
||||
"titleIPBanned": "IP banned",
|
||||
"titleLoginFailed": "Falha de login",
|
||||
"titleLoginSuccess": "Login bem-sucedido",
|
||||
"titleNodeOffline": "Node OFFLINE",
|
||||
"titleNodeOnline": "Node ONLINE",
|
||||
"titleNodeXrayDown": "Node Xray DOWN",
|
||||
"titleNodeXrayUp": "Node Xray UP",
|
||||
"titleOutboundDown": "Outbound INATIVO",
|
||||
"titleOutboundUp": "Outbound ATIVO",
|
||||
"titleXrayCrash": "O Xray FALHOU",
|
||||
"titleXrayUp": "Xray UP",
|
||||
"labelNode": "Nó"
|
||||
}
|
||||
}
|
||||
@ -557,6 +557,15 @@
|
||||
"tcpCongestion": "TCP Congestion",
|
||||
"dialerProxy": "Dialer Proxy",
|
||||
"trustedXForwardedFor": "Доверенный X-Forwarded-For",
|
||||
"trustedXForwardedForHint": "Доверять этому заголовку запроса для определения реального IP клиента (например, CF-Connecting-IP за CDN Cloudflare). Работает только на транспортах WebSocket, HTTPUpgrade и XHTTP. Оставьте пустым, чтобы игнорировать заголовки пересылки.",
|
||||
"proxyProtocolHint": "Принимать заголовок PROXY protocol, чтобы получить реальный IP клиента от вышестоящего L4-туннеля или релея (HAProxy, gost, nginx-stream, Xray dokodemo-door) либо Cloudflare Spectrum. Вышестоящий узел ДОЛЖЕН отправлять PROXY protocol. Работает на TCP, WebSocket, HTTPUpgrade и gRPC; не работает на mKCP.",
|
||||
"realClientIp": "Реальный IP клиента",
|
||||
"realClientIpHint": "Получать реальный IP посетителя, когда трафик приходит на этот входящий через CDN или релей, вместо адреса промежуточного узла. Выберите пресет, чтобы заполнить соответствующие поля sockopt ниже. Эти поля никогда не отправляются клиентам в подписках.",
|
||||
"realClientIpPresetOff": "Выкл. / напрямую",
|
||||
"realClientIpPresetCloudflare": "Cloudflare CDN",
|
||||
"realClientIpPresetProxyProtocol": "L4-релей / Spectrum (PROXY)",
|
||||
"realClientIpTrustedHeaderTransportWarn": "Trusted X-Forwarded-For работает только на WebSocket, HTTPUpgrade и XHTTP. На текущем транспорте этот заголовок игнорируется.",
|
||||
"realClientIpProxyProtocolTransportWarn": "PROXY protocol не поддерживается на этом транспорте (mKCP). Используйте TCP/RAW, WebSocket, HTTPUpgrade, gRPC или XHTTP.",
|
||||
"addressPortStrategy": "Стратегия адрес+порт",
|
||||
"tryDelayMs": "Задержка попытки (мс)",
|
||||
"prioritizeIPv6": "Приоритет IPv6",
|
||||
@ -1200,7 +1209,69 @@
|
||||
"userPassMustBeNotEmpty": "Новое имя пользователя и новый пароль должны быть заполнены",
|
||||
"getOutboundTrafficError": "Ошибка получения трафика исходящего подключения",
|
||||
"resetOutboundTrafficError": "Ошибка сброса трафика исходящего подключения"
|
||||
}
|
||||
},
|
||||
"smtpSettings": "Настройки SMTP",
|
||||
"smtpEnable": "Включить уведомления по Email",
|
||||
"smtpEnableDesc": "Включить уведомления по email через SMTP",
|
||||
"smtpHost": "SMTP хост",
|
||||
"smtpHostDesc": "Имя хоста SMTP сервера (например smtp.gmail.com)",
|
||||
"smtpPort": "SMTP порт",
|
||||
"smtpPortDesc": "Порт SMTP сервера (по умолчанию: 587)",
|
||||
"smtpUsername": "SMTP логин",
|
||||
"smtpUsernameDesc": "Логин для аутентификации SMTP",
|
||||
"smtpPassword": "SMTP пароль",
|
||||
"smtpPasswordDesc": "Пароль для аутентификации SMTP",
|
||||
"smtpTo": "Получатели",
|
||||
"smtpToDesc": "Адреса получателей через запятую",
|
||||
"emailSettings": "Email",
|
||||
"emailNotifications": "Уведомления",
|
||||
"smtpEventBusNotify": "Email уведомления о событиях",
|
||||
"smtpEventBusNotifyDesc": "Выберите события для email уведомлений",
|
||||
"tgEventBusNotify": "Telegram уведомления о событиях",
|
||||
"tgEventBusNotifyDesc": "Выберите события для Telegram уведомлений",
|
||||
"testSmtp": "Отправить тестовое письмо",
|
||||
"testTgBot": "Отправить тестовое сообщение",
|
||||
"eventGroupOutbound": "Исходящие",
|
||||
"eventGroupXray": "Ядро Xray",
|
||||
"eventGroupSystem": "Система",
|
||||
"eventGroupSecurity": "Безопасность",
|
||||
"eventOutboundDown": "Недоступен",
|
||||
"eventOutboundUp": "Работает",
|
||||
"eventXrayCrash": "Сбой",
|
||||
"eventCPUHigh": "Превышение порога CPU (%)",
|
||||
"requestFailed": "Запрос не удался",
|
||||
"smtpEncryption": "Шифрование",
|
||||
"smtpEncryptionDesc": "Метод шифрования SMTP соединения",
|
||||
"smtpEncryptionNone": "Нет (открытый текст)",
|
||||
"smtpEncryptionStartTLS": "STARTTLS",
|
||||
"smtpEncryptionTLS": "TLS (неявное)",
|
||||
"smtpStageConnect": "Подключение",
|
||||
"smtpStageAuth": "Аутентификация",
|
||||
"smtpStageSend": "Отправка",
|
||||
"smtpTestSuccess": "Тестовое письмо отправлено успешно",
|
||||
"smtpHostNotConfigured": "SMTP хост не настроен",
|
||||
"smtpNoRecipients": "Получатели не настроены",
|
||||
"eventLoginAttempt": "Попытка входа",
|
||||
"telegramTokenConfigured": "Настроен; оставьте пустым для сохранения текущего токена.",
|
||||
"telegramTokenPlaceholder": "Настроен - введите новый токен для замены",
|
||||
"smtpNotInitialized": "SMTP не инициализирован",
|
||||
"tgBotNotEnabled": "Telegram бот не включен",
|
||||
"tgTestFailed": "Тест Telegram не удался",
|
||||
"tgTestSuccess": "Тестовое сообщение отправлено в Telegram",
|
||||
"tgBotNotRunning": "Telegram бот не запущен",
|
||||
"smtpErrorAuth": "Ошибка аутентификации — проверьте логин и пароль",
|
||||
"smtpErrorStarttls": "Сервер требует STARTTLS — измените тип шифрования",
|
||||
"smtpErrorTls": "Сервер требует TLS — измените тип шифрования",
|
||||
"smtpErrorRefused": "Соединение отклонено — проверьте хост и порт",
|
||||
"smtpErrorTimeout": "Таймаут соединения — хост недоступен",
|
||||
"smtpErrorRelay": "Сервер отклоняет отправку с этого адреса",
|
||||
"smtpErrorEof": "Соединение закрыто сервером",
|
||||
"smtpErrorUnknown": "Ошибка SMTP: {{ .Error }}",
|
||||
"eventGroupNode": "Узлы",
|
||||
"eventNodeDown": "Недоступен",
|
||||
"eventNodeUp": "В сети",
|
||||
"smtpPasswordConfigured": "Настроен; оставьте пустым для сохранения текущего пароля.",
|
||||
"smtpPasswordPlaceholder": "Настроен - введите новый пароль для замены"
|
||||
},
|
||||
"xray": {
|
||||
"title": "Настройки Xray",
|
||||
@ -1703,7 +1774,18 @@
|
||||
"AreYouSure": "Вы уверены? 🤔",
|
||||
"SuccessResetTraffic": "📧 Почта: {{ .ClientEmail }}\n🏁 Результат: ✅ Успешно",
|
||||
"FailedResetTraffic": "📧 Почта: {{ .ClientEmail }}\n🏁 Результат: ❌ Неудача \n\n🛠️ Ошибка: [ {{ .ErrorMessage }} ]",
|
||||
"FinishProcess": "🔚 Сброс трафика завершён для всех клиентов."
|
||||
"FinishProcess": "🔚 Сброс трафика завершён для всех клиентов.",
|
||||
"eventOutboundDown": "Исходящее подключение {{ .Tag }} НЕДОСТУПНО",
|
||||
"eventOutboundUp": "Исходящее подключение {{ .Tag }} РАБОТАЕТ",
|
||||
"eventXrayCrash": "Сбой Xray",
|
||||
"eventXrayCrashError": "Ошибка: {{ .Error }}",
|
||||
"eventCPUHigh": "Высокая загрузка CPU",
|
||||
"eventCPUHighDetail": "CPU: {{ .Detail }}",
|
||||
"eventLoginFallback": "Неудачный вход с {{ .Source }}",
|
||||
"eventDelayDetail": "Задержка: {{ .Delay }} мс",
|
||||
"eventErrorDetail": "Ошибка: {{ .Error }}",
|
||||
"eventNodeDown": "Узел {{ .Name }} НЕДОСТУПЕН",
|
||||
"eventNodeUp": "Узел {{ .Name }} В СЕТИ"
|
||||
},
|
||||
"buttons": {
|
||||
"closeKeyboard": "❌ Закрыть клавиатуру",
|
||||
@ -1773,5 +1855,37 @@
|
||||
"chooseClient": "Выберите клиента для входящего подключения {{ .Inbound }}",
|
||||
"chooseInbound": "Выберите входящее подключение"
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"subjectOutboundDown": "Исходящее подключение {{ .Tag }} НЕДОСТУПНО",
|
||||
"subjectOutboundUp": "Исходящее подключение {{ .Tag }} РАБОТАЕТ",
|
||||
"subjectXrayCrash": "Сбой Xray",
|
||||
"subjectCPUHigh": "Высокая загрузка CPU",
|
||||
"subjectLoginSuccess": "Успешный вход",
|
||||
"subjectLoginFailed": "Неудачный вход",
|
||||
"titleOutboundDown": "Исходящее подключение НЕДОСТУПНО",
|
||||
"titleOutboundUp": "Исходящее подключение РАБОТАЕТ",
|
||||
"titleXrayCrash": "Сбой Xray",
|
||||
"titleCPUHigh": "Высокая загрузка CPU",
|
||||
"titleLoginSuccess": "Успешный вход",
|
||||
"titleLoginFailed": "Неудачный вход",
|
||||
"labelStatus": "Статус",
|
||||
"labelOutbound": "Исходящее подключение",
|
||||
"labelError": "Ошибка",
|
||||
"labelDelay": "Задержка",
|
||||
"labelDetail": "Подробности",
|
||||
"labelUsername": "Имя пользователя",
|
||||
"labelIP": "IP",
|
||||
"labelReason": "Причина",
|
||||
"labelSource": "Источник",
|
||||
"labelTime": "Время",
|
||||
"statusCrashed": "СБОЙ",
|
||||
"statusRunning": "Работает",
|
||||
"statusHigh": "ВЫСОКАЯ",
|
||||
"statusSuccess": "УСПЕШНО",
|
||||
"statusFailed": "НЕУДАЧНО",
|
||||
"statusDown": "НЕДОСТУПЕН",
|
||||
"statusUp": "РАБОТАЕТ",
|
||||
"labelNode": "Узел"
|
||||
}
|
||||
}
|
||||
@ -558,6 +558,15 @@
|
||||
"tcpCongestion": "TCP Congestion",
|
||||
"dialerProxy": "Dialer Proxy",
|
||||
"trustedXForwardedFor": "Güvenilir X-Forwarded-For",
|
||||
"trustedXForwardedForHint": "Gerçek istemci IP'sini almak için bu istek başlığına güven (örn. Cloudflare CDN arkasındaki CF-Connecting-IP). Yalnızca WebSocket, HTTPUpgrade ve XHTTP taşımalarında geçerlidir. İletilen başlıkları yok saymak için boş bırakın.",
|
||||
"proxyProtocolHint": "Gerçek istemci IP'sini bir üst L4 tüneli veya rölesi (HAProxy, gost, nginx-stream, Xray dokodemo-door) ya da Cloudflare Spectrum üzerinden öğrenmek için PROXY protocol başlığını kabul et. Üst sunucu PROXY protocol göndermek ZORUNDADIR. TCP, WebSocket, HTTPUpgrade ve gRPC üzerinde çalışır; mKCP üzerinde çalışmaz.",
|
||||
"realClientIp": "Gerçek istemci IP'si",
|
||||
"realClientIpHint": "Trafik bu gelen bağlantıya bir CDN veya röle üzerinden ulaştığında, aracı adresini kaydetmek yerine ziyaretçinin gerçek IP'sini al. Aşağıdaki ilgili sockopt alanlarını doldurmak için bir hazır ayar seç. Bu alanlar aboneliklerde istemcilere asla gönderilmez.",
|
||||
"realClientIpPresetOff": "Kapalı / doğrudan",
|
||||
"realClientIpPresetCloudflare": "Cloudflare CDN",
|
||||
"realClientIpPresetProxyProtocol": "L4 röle / Spectrum (PROXY)",
|
||||
"realClientIpTrustedHeaderTransportWarn": "Trusted X-Forwarded-For yalnızca WebSocket, HTTPUpgrade ve XHTTP üzerinde geçerlidir. Mevcut taşımada bu başlık yok sayılır.",
|
||||
"realClientIpProxyProtocolTransportWarn": "PROXY protocol bu taşımada (mKCP) desteklenmez. TCP/RAW, WebSocket, HTTPUpgrade, gRPC veya XHTTP kullanın.",
|
||||
"addressPortStrategy": "Adres+Port Stratejisi",
|
||||
"tryDelayMs": "Deneme Gecikmesi (ms)",
|
||||
"prioritizeIPv6": "IPv6 Önceliği",
|
||||
@ -1199,7 +1208,69 @@
|
||||
"userPassMustBeNotEmpty": "Yeni kullanıcı adı ve şifre boş olamaz.",
|
||||
"getOutboundTrafficError": "Giden trafik alınırken hata oluştu.",
|
||||
"resetOutboundTrafficError": "Giden trafik sıfırlanırken hata oluştu."
|
||||
}
|
||||
},
|
||||
"emailNotifications": "Bildirimler",
|
||||
"emailSettings": "E-posta",
|
||||
"eventCPUHigh": "Yüksek CPU (%)",
|
||||
"eventGroupOutbound": "Giden Bağlantı",
|
||||
"eventGroupSecurity": "Güvenlik",
|
||||
"eventGroupSystem": "Sistem",
|
||||
"eventGroupXray": "Xray Çekirdeği",
|
||||
"eventLoginAttempt": "Oturum açma denemesi",
|
||||
"eventOutboundDown": "Çevrimdışı",
|
||||
"eventOutboundUp": "Çevrimiçi",
|
||||
"eventXrayCrash": "Çökme",
|
||||
"requestFailed": "İstek başarısız oldu",
|
||||
"smtpEnable": "E-posta Bildirimlerini Etkinleştir",
|
||||
"smtpEnableDesc": "SMTP üzerinden e-posta bildirimlerini etkinleştirin",
|
||||
"smtpEncryption": "Şifreleme",
|
||||
"smtpEncryptionDesc": "SMTP bağlantı şifreleme yöntemi",
|
||||
"smtpEncryptionNone": "Yok (düz metin)",
|
||||
"smtpEncryptionStartTLS": "STARTTLS",
|
||||
"smtpEncryptionTLS": "TLS (örtük)",
|
||||
"smtpEventBusNotify": "E-posta Olay Bildirimleri",
|
||||
"smtpEventBusNotifyDesc": "Hangi olayların e-posta bildirimi tetikleyeceğini seçin",
|
||||
"smtpHost": "SMTP Sunucusu",
|
||||
"smtpHostDesc": "SMTP sunucu ana bilgisayar adı (örn. smtp.gmail.com)",
|
||||
"smtpHostNotConfigured": "SMTP sunucusu yapılandırılmamış",
|
||||
"smtpNoRecipients": "Yapılandırılmış alıcı yok",
|
||||
"smtpNotInitialized": "SMTP başlatılmadı",
|
||||
"smtpPassword": "SMTP Parolası",
|
||||
"smtpPasswordDesc": "SMTP kimlik doğrulama parolası",
|
||||
"smtpPort": "SMTP Bağlantı Noktası",
|
||||
"smtpPortDesc": "SMTP sunucu bağlantı noktası (varsayılan: 587)",
|
||||
"smtpSettings": "SMTP Ayarları",
|
||||
"smtpStageAuth": "Kimlik Doğrulama",
|
||||
"smtpStageConnect": "Bağlantı",
|
||||
"smtpStageSend": "Gönderim",
|
||||
"smtpTestSuccess": "Test e-postası başarıyla gönderildi",
|
||||
"smtpTo": "Alıcılar",
|
||||
"smtpToDesc": "Virgülle ayrılmış alıcı e-posta adresleri",
|
||||
"smtpUsername": "SMTP Kullanıcı Adı",
|
||||
"smtpUsernameDesc": "SMTP kimlik doğrulama kullanıcı adı",
|
||||
"telegramTokenConfigured": "Yapılandırıldı; mevcut belirteci korumak için boş bırakın.",
|
||||
"telegramTokenPlaceholder": "Yapılandırıldı - değiştirmek için yeni bir belirteç girin",
|
||||
"testSmtp": "Test E-postası Gönder",
|
||||
"testTgBot": "Test Mesajı Gönder",
|
||||
"tgBotNotEnabled": "Telegram botu etkin değil",
|
||||
"tgBotNotRunning": "Telegram botu çalışmıyor",
|
||||
"tgEventBusNotify": "Telegram Olay Bildirimleri",
|
||||
"tgEventBusNotifyDesc": "Hangi olayların Telegram bildirimi tetikleyeceğini seçin",
|
||||
"tgTestFailed": "Telegram testi başarısız oldu",
|
||||
"tgTestSuccess": "Test mesajı Telegram'a gönderildi",
|
||||
"smtpErrorAuth": "Kimlik doğrulama başarısız — kullanıcı adını ve parolayı kontrol edin",
|
||||
"smtpErrorStarttls": "Sunucu STARTTLS gerektiriyor — şifreleme türünü değiştirin",
|
||||
"smtpErrorTls": "Sunucu TLS gerektiriyor — şifreleme türünü değiştirin",
|
||||
"smtpErrorRefused": "Bağlantı reddedildi — sunucuyu ve bağlantı noktasını kontrol edin",
|
||||
"smtpErrorTimeout": "Bağlantı zaman aşımına uğradı — sunucuya ulaşılamıyor",
|
||||
"smtpErrorRelay": "Sunucu bu adresten gönderimi reddediyor",
|
||||
"smtpErrorEof": "Bağlantı sunucu tarafından kapatıldı",
|
||||
"smtpErrorUnknown": "SMTP hatası: {{ .Error }}",
|
||||
"eventGroupNode": "Düğümler",
|
||||
"eventNodeDown": "Çevrimdışı",
|
||||
"eventNodeUp": "Çevrimiçi",
|
||||
"smtpPasswordConfigured": "Yapılandırıldı; mevcut parolayı korumak için boş bırakın.",
|
||||
"smtpPasswordPlaceholder": "Yapılandırıldı - değiştirmek için yeni bir parola girin"
|
||||
},
|
||||
"xray": {
|
||||
"title": "Xray Yapılandırmaları",
|
||||
@ -1702,7 +1773,18 @@
|
||||
"AreYouSure": "Emin misiniz? 🤔",
|
||||
"SuccessResetTraffic": "📧 E-posta: {{ .ClientEmail }}\n🏁 Sonuç: ✅ Başarılı",
|
||||
"FailedResetTraffic": "📧 E-posta: {{ .ClientEmail }}\n🏁 Sonuç: ❌ Başarısız \n\n🛠️ Hata: [ {{ .ErrorMessage }} ]",
|
||||
"FinishProcess": "🔚 Tüm kullanıcılar için trafik sıfırlama işlemi tamamlandı."
|
||||
"FinishProcess": "🔚 Tüm kullanıcılar için trafik sıfırlama işlemi tamamlandı.",
|
||||
"eventCPUHigh": "Yüksek CPU",
|
||||
"eventCPUHighDetail": "CPU: {{ .Detail }}",
|
||||
"eventDelayDetail": "Gecikme: {{ .Delay }}ms",
|
||||
"eventErrorDetail": "Hata: {{ .Error }}",
|
||||
"eventLoginFallback": "{{ .Source }} adresinden oturum açma başarısız",
|
||||
"eventOutboundDown": "{{ .Tag }} giden bağlantısı ÇEVRİMDIŞI",
|
||||
"eventOutboundUp": "{{ .Tag }} giden bağlantısı ÇEVRİMİÇİ",
|
||||
"eventXrayCrash": "Xray ÇÖKTÜ",
|
||||
"eventXrayCrashError": "Hata: {{ .Error }}",
|
||||
"eventNodeDown": "{{ .Name }} düğümü ÇEVRİMDIŞI",
|
||||
"eventNodeUp": "{{ .Name }} düğümü ÇEVRİMİÇİ"
|
||||
},
|
||||
"buttons": {
|
||||
"closeKeyboard": "❌ Klavyeyi Kapat",
|
||||
@ -1772,5 +1854,57 @@
|
||||
"chooseClient": "Gelen Bağlantı {{ .Inbound }} için bir Kullanıcı Seçin",
|
||||
"chooseInbound": "Bir Gelen Bağlantı Seçin"
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"labelDelay": "Gecikme",
|
||||
"labelDetail": "Ayrıntı",
|
||||
"labelError": "Hata",
|
||||
"labelIP": "IP",
|
||||
"labelOutbound": "Giden Bağlantı",
|
||||
"labelReason": "Neden",
|
||||
"labelSource": "Kaynak",
|
||||
"labelStatus": "Durum",
|
||||
"labelTime": "Zaman",
|
||||
"labelUsername": "Kullanıcı Adı",
|
||||
"statusBanned": "BANNED",
|
||||
"statusCrashed": "ÇÖKTÜ",
|
||||
"statusDown": "ÇEVRİMDIŞI",
|
||||
"statusFailed": "BAŞARISIZ",
|
||||
"statusFull": "FULL",
|
||||
"statusHigh": "YÜKSEK",
|
||||
"statusOffline": "OFFLINE",
|
||||
"statusOnline": "ONLINE",
|
||||
"statusRunning": "Çalışıyor",
|
||||
"statusSuccess": "BAŞARILI",
|
||||
"statusUp": "ÇEVRİMİÇİ",
|
||||
"statusXrayDown": "Xray DOWN",
|
||||
"statusXrayUp": "Xray UP",
|
||||
"subjectCPUHigh": "Yüksek CPU",
|
||||
"subjectDiskFull": "Disk full",
|
||||
"subjectIPBanned": "IP banned: {{ .IP }}",
|
||||
"subjectLoginFailed": "Oturum açma başarısız",
|
||||
"subjectLoginSuccess": "Oturum açma başarılı",
|
||||
"subjectNodeOffline": "Node {{ .Node }} is OFFLINE",
|
||||
"subjectNodeOnline": "Node {{ .Node }} is ONLINE",
|
||||
"subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN",
|
||||
"subjectNodeXrayUp": "Node {{ .Node }} Xray is UP",
|
||||
"subjectOutboundDown": "{{ .Tag }} giden bağlantısı ÇEVRİMDIŞI",
|
||||
"subjectOutboundUp": "{{ .Tag }} giden bağlantısı ÇEVRİMİÇİ",
|
||||
"subjectXrayCrash": "Xray ÇÖKTÜ",
|
||||
"subjectXrayUp": "Xray is UP",
|
||||
"titleCPUHigh": "Yüksek CPU",
|
||||
"titleDiskFull": "Disk full",
|
||||
"titleIPBanned": "IP banned",
|
||||
"titleLoginFailed": "Oturum açma başarısız",
|
||||
"titleLoginSuccess": "Oturum açma başarılı",
|
||||
"titleNodeOffline": "Node OFFLINE",
|
||||
"titleNodeOnline": "Node ONLINE",
|
||||
"titleNodeXrayDown": "Node Xray DOWN",
|
||||
"titleNodeXrayUp": "Node Xray UP",
|
||||
"titleOutboundDown": "Giden Bağlantı ÇEVRİMDIŞI",
|
||||
"titleOutboundUp": "Giden Bağlantı ÇEVRİMİÇİ",
|
||||
"titleXrayCrash": "Xray ÇÖKTÜ",
|
||||
"titleXrayUp": "Xray UP",
|
||||
"labelNode": "Düğüm"
|
||||
}
|
||||
}
|
||||
@ -557,6 +557,15 @@
|
||||
"tcpCongestion": "TCP Congestion",
|
||||
"dialerProxy": "Dialer Proxy",
|
||||
"trustedXForwardedFor": "Довірений X-Forwarded-For",
|
||||
"trustedXForwardedForHint": "Довіряти цьому заголовку запиту для визначення справжнього IP клієнта (наприклад, CF-Connecting-IP за CDN Cloudflare). Працює лише на транспортах WebSocket, HTTPUpgrade та XHTTP. Залиште порожнім, щоб ігнорувати заголовки пересилання.",
|
||||
"proxyProtocolHint": "Приймати заголовок PROXY protocol, щоб отримати справжній IP клієнта від висхідного L4-тунелю чи релея (HAProxy, gost, nginx-stream, Xray dokodemo-door) або Cloudflare Spectrum. Висхідний вузол МУСИТЬ надсилати PROXY protocol. Працює на TCP, WebSocket, HTTPUpgrade та gRPC; не працює на mKCP.",
|
||||
"realClientIp": "Справжній IP клієнта",
|
||||
"realClientIpHint": "Отримувати справжній IP відвідувача, коли трафік надходить на цей вхідний через CDN або релей, замість адреси проміжного вузла. Виберіть пресет, щоб заповнити відповідні поля sockopt нижче. Ці поля ніколи не надсилаються клієнтам у підписках.",
|
||||
"realClientIpPresetOff": "Вимк. / напряму",
|
||||
"realClientIpPresetCloudflare": "Cloudflare CDN",
|
||||
"realClientIpPresetProxyProtocol": "L4-релей / Spectrum (PROXY)",
|
||||
"realClientIpTrustedHeaderTransportWarn": "Trusted X-Forwarded-For працює лише на WebSocket, HTTPUpgrade та XHTTP. На поточному транспорті цей заголовок ігнорується.",
|
||||
"realClientIpProxyProtocolTransportWarn": "PROXY protocol не підтримується на цьому транспорті (mKCP). Використовуйте TCP/RAW, WebSocket, HTTPUpgrade, gRPC або XHTTP.",
|
||||
"addressPortStrategy": "Стратегія адрес+порт",
|
||||
"tryDelayMs": "Затримка спроби (мс)",
|
||||
"prioritizeIPv6": "Пріоритет IPv6",
|
||||
@ -1200,7 +1209,69 @@
|
||||
"userPassMustBeNotEmpty": "Нове ім'я користувача та пароль порожні",
|
||||
"getOutboundTrafficError": "Помилка отримання вихідного трафіку",
|
||||
"resetOutboundTrafficError": "Помилка скидання вихідного трафіку"
|
||||
}
|
||||
},
|
||||
"emailNotifications": "Сповіщення",
|
||||
"emailSettings": "Електронна пошта",
|
||||
"eventCPUHigh": "Високе навантаження на CPU (%)",
|
||||
"eventGroupOutbound": "Вихідні з'єднання",
|
||||
"eventGroupSecurity": "Безпека",
|
||||
"eventGroupSystem": "Система",
|
||||
"eventGroupXray": "Ядро Xray",
|
||||
"eventLoginAttempt": "Спроба входу",
|
||||
"eventOutboundDown": "Недоступне",
|
||||
"eventOutboundUp": "Доступне",
|
||||
"eventXrayCrash": "Збій",
|
||||
"requestFailed": "Запит не вдалося виконати",
|
||||
"smtpEnable": "Увімкнути сповіщення електронною поштою",
|
||||
"smtpEnableDesc": "Увімкнути сповіщення електронною поштою через SMTP",
|
||||
"smtpEncryption": "Шифрування",
|
||||
"smtpEncryptionDesc": "Метод шифрування з'єднання SMTP",
|
||||
"smtpEncryptionNone": "Немає (відкритий текст)",
|
||||
"smtpEncryptionStartTLS": "STARTTLS",
|
||||
"smtpEncryptionTLS": "TLS (неявне)",
|
||||
"smtpEventBusNotify": "Сповіщення про події електронною поштою",
|
||||
"smtpEventBusNotifyDesc": "Виберіть, які події спричиняють сповіщення електронною поштою",
|
||||
"smtpHost": "Хост SMTP",
|
||||
"smtpHostDesc": "Ім'я хоста сервера SMTP (наприклад, smtp.gmail.com)",
|
||||
"smtpHostNotConfigured": "Хост SMTP не налаштовано",
|
||||
"smtpNoRecipients": "Отримувачів не налаштовано",
|
||||
"smtpNotInitialized": "SMTP не ініціалізовано",
|
||||
"smtpPassword": "Пароль SMTP",
|
||||
"smtpPasswordDesc": "Пароль для автентифікації SMTP",
|
||||
"smtpPort": "Порт SMTP",
|
||||
"smtpPortDesc": "Порт сервера SMTP (типово: 587)",
|
||||
"smtpSettings": "Налаштування SMTP",
|
||||
"smtpStageAuth": "Автентифікація",
|
||||
"smtpStageConnect": "З'єднання",
|
||||
"smtpStageSend": "Надсилання",
|
||||
"smtpTestSuccess": "Тестовий лист успішно надіслано",
|
||||
"smtpTo": "Отримувачі",
|
||||
"smtpToDesc": "Адреси електронної пошти отримувачів, розділені комами",
|
||||
"smtpUsername": "Ім'я користувача SMTP",
|
||||
"smtpUsernameDesc": "Ім'я користувача для автентифікації SMTP",
|
||||
"telegramTokenConfigured": "Налаштовано; залиште порожнім, щоб зберегти поточний токен.",
|
||||
"telegramTokenPlaceholder": "Налаштовано — введіть новий токен для заміни",
|
||||
"testSmtp": "Надіслати тестовий лист",
|
||||
"testTgBot": "Надіслати тестове повідомлення",
|
||||
"tgBotNotEnabled": "Бот Telegram не увімкнено",
|
||||
"tgBotNotRunning": "Бот Telegram не запущено",
|
||||
"tgEventBusNotify": "Сповіщення про події в Telegram",
|
||||
"tgEventBusNotifyDesc": "Виберіть, які події спричиняють сповіщення в Telegram",
|
||||
"tgTestFailed": "Тест Telegram не вдався",
|
||||
"tgTestSuccess": "Тестове повідомлення надіслано в Telegram",
|
||||
"smtpErrorAuth": "Помилка автентифікації — перевірте ім'я користувача та пароль",
|
||||
"smtpErrorStarttls": "Сервер вимагає STARTTLS — змініть тип шифрування",
|
||||
"smtpErrorTls": "Сервер вимагає TLS — змініть тип шифрування",
|
||||
"smtpErrorRefused": "У з'єднанні відмовлено — перевірте хост і порт",
|
||||
"smtpErrorTimeout": "Час очікування з'єднання вичерпано — хост недоступний",
|
||||
"smtpErrorRelay": "Сервер відхиляє надсилання з цієї адреси",
|
||||
"smtpErrorEof": "З'єднання закрито сервером",
|
||||
"smtpErrorUnknown": "Помилка SMTP: {{ .Error }}",
|
||||
"eventGroupNode": "Вузли",
|
||||
"eventNodeDown": "Недоступний",
|
||||
"eventNodeUp": "Доступний",
|
||||
"smtpPasswordConfigured": "Налаштовано; залиште порожнім, щоб зберегти поточний пароль.",
|
||||
"smtpPasswordPlaceholder": "Налаштовано — введіть новий пароль для заміни"
|
||||
},
|
||||
"xray": {
|
||||
"title": "Xray конфігурації",
|
||||
@ -1703,7 +1774,18 @@
|
||||
"AreYouSure": "Ви впевнені? 🤔",
|
||||
"SuccessResetTraffic": "📧 Електронна пошта: {{ .ClientEmail }}\n🏁 Результат: ✅ Успішно",
|
||||
"FailedResetTraffic": "📧 Електронна пошта: {{ .ClientEmail }}\n🏁 Результат: ❌ Невдача \n\n🛠️ Помилка: [ {{ .ErrorMessage }} ]",
|
||||
"FinishProcess": "🔚 Процес скидання трафіку завершено для всіх клієнтів."
|
||||
"FinishProcess": "🔚 Процес скидання трафіку завершено для всіх клієнтів.",
|
||||
"eventCPUHigh": "Високе навантаження на CPU",
|
||||
"eventCPUHighDetail": "CPU: {{ .Detail }}",
|
||||
"eventDelayDetail": "Затримка: {{ .Delay }} мс",
|
||||
"eventErrorDetail": "Помилка: {{ .Error }}",
|
||||
"eventLoginFallback": "Невдала спроба входу з {{ .Source }}",
|
||||
"eventOutboundDown": "Вихідне з'єднання {{ .Tag }} НЕДОСТУПНЕ",
|
||||
"eventOutboundUp": "Вихідне з'єднання {{ .Tag }} ДОСТУПНЕ",
|
||||
"eventXrayCrash": "Стався збій Xray",
|
||||
"eventXrayCrashError": "Помилка: {{ .Error }}",
|
||||
"eventNodeDown": "Вузол {{ .Name }} НЕДОСТУПНИЙ",
|
||||
"eventNodeUp": "Вузол {{ .Name }} ДОСТУПНИЙ"
|
||||
},
|
||||
"buttons": {
|
||||
"closeKeyboard": "❌ Закрити клавіатуру",
|
||||
@ -1773,5 +1855,57 @@
|
||||
"chooseClient": "Виберіть клієнта для Вхідного {{ .Inbound }}",
|
||||
"chooseInbound": "Виберіть Вхідний"
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"labelDelay": "Затримка",
|
||||
"labelDetail": "Деталі",
|
||||
"labelError": "Помилка",
|
||||
"labelIP": "IP",
|
||||
"labelOutbound": "Вихідне з'єднання",
|
||||
"labelReason": "Причина",
|
||||
"labelSource": "Джерело",
|
||||
"labelStatus": "Статус",
|
||||
"labelTime": "Час",
|
||||
"labelUsername": "Ім'я користувача",
|
||||
"statusBanned": "BANNED",
|
||||
"statusCrashed": "ЗБІЙ",
|
||||
"statusDown": "НЕДОСТУПНО",
|
||||
"statusFailed": "НЕВДАЛО",
|
||||
"statusFull": "FULL",
|
||||
"statusHigh": "ВИСОКЕ",
|
||||
"statusOffline": "OFFLINE",
|
||||
"statusOnline": "ONLINE",
|
||||
"statusRunning": "Працює",
|
||||
"statusSuccess": "УСПІШНО",
|
||||
"statusUp": "ДОСТУПНО",
|
||||
"statusXrayDown": "Xray DOWN",
|
||||
"statusXrayUp": "Xray UP",
|
||||
"subjectCPUHigh": "Високе навантаження на CPU",
|
||||
"subjectDiskFull": "Disk full",
|
||||
"subjectIPBanned": "IP banned: {{ .IP }}",
|
||||
"subjectLoginFailed": "Невдалий вхід",
|
||||
"subjectLoginSuccess": "Успішний вхід",
|
||||
"subjectNodeOffline": "Node {{ .Node }} is OFFLINE",
|
||||
"subjectNodeOnline": "Node {{ .Node }} is ONLINE",
|
||||
"subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN",
|
||||
"subjectNodeXrayUp": "Node {{ .Node }} Xray is UP",
|
||||
"subjectOutboundDown": "Вихідне з'єднання {{ .Tag }} НЕДОСТУПНЕ",
|
||||
"subjectOutboundUp": "Вихідне з'єднання {{ .Tag }} ДОСТУПНЕ",
|
||||
"subjectXrayCrash": "Стався збій Xray",
|
||||
"subjectXrayUp": "Xray is UP",
|
||||
"titleCPUHigh": "Високе навантаження на CPU",
|
||||
"titleDiskFull": "Disk full",
|
||||
"titleIPBanned": "IP banned",
|
||||
"titleLoginFailed": "Невдалий вхід",
|
||||
"titleLoginSuccess": "Успішний вхід",
|
||||
"titleNodeOffline": "Node OFFLINE",
|
||||
"titleNodeOnline": "Node ONLINE",
|
||||
"titleNodeXrayDown": "Node Xray DOWN",
|
||||
"titleNodeXrayUp": "Node Xray UP",
|
||||
"titleOutboundDown": "Вихідне з'єднання НЕДОСТУПНЕ",
|
||||
"titleOutboundUp": "Вихідне з'єднання ДОСТУПНЕ",
|
||||
"titleXrayCrash": "Стався збій Xray",
|
||||
"titleXrayUp": "Xray UP",
|
||||
"labelNode": "Вузол"
|
||||
}
|
||||
}
|
||||
@ -557,6 +557,15 @@
|
||||
"tcpCongestion": "TCP Congestion",
|
||||
"dialerProxy": "Dialer Proxy",
|
||||
"trustedXForwardedFor": "X-Forwarded-For tin cậy",
|
||||
"trustedXForwardedForHint": "Tin cậy header yêu cầu này để lấy IP thật của client (ví dụ CF-Connecting-IP phía sau CDN của Cloudflare). Chỉ có hiệu lực trên các transport WebSocket, HTTPUpgrade và XHTTP. Để trống để bỏ qua các header chuyển tiếp.",
|
||||
"proxyProtocolHint": "Chấp nhận header PROXY protocol để lấy IP thật của client từ tunnel/relay L4 phía trên (HAProxy, gost, nginx-stream, Xray dokodemo-door) hoặc Cloudflare Spectrum. Phía trên PHẢI gửi PROXY protocol. Hoạt động trên TCP, WebSocket, HTTPUpgrade và gRPC; không hoạt động trên mKCP.",
|
||||
"realClientIp": "IP thật của client",
|
||||
"realClientIpHint": "Lấy IP thật của khách khi lưu lượng đến inbound này qua CDN hoặc relay, thay vì ghi lại địa chỉ của trung gian. Chọn một preset để tự điền các trường sockopt tương ứng bên dưới. Các trường này không bao giờ được gửi đến client trong subscription.",
|
||||
"realClientIpPresetOff": "Tắt / trực tiếp",
|
||||
"realClientIpPresetCloudflare": "Cloudflare CDN",
|
||||
"realClientIpPresetProxyProtocol": "Relay L4 / Spectrum (PROXY)",
|
||||
"realClientIpTrustedHeaderTransportWarn": "Trusted X-Forwarded-For chỉ có hiệu lực trên WebSocket, HTTPUpgrade và XHTTP. Trên transport hiện tại header này bị bỏ qua.",
|
||||
"realClientIpProxyProtocolTransportWarn": "PROXY protocol không được hỗ trợ trên transport này (mKCP). Hãy dùng TCP/RAW, WebSocket, HTTPUpgrade, gRPC hoặc XHTTP.",
|
||||
"addressPortStrategy": "Chiến lược địa chỉ+cổng",
|
||||
"tryDelayMs": "Độ trễ thử (ms)",
|
||||
"prioritizeIPv6": "Ưu tiên IPv6",
|
||||
@ -1200,7 +1209,69 @@
|
||||
"userPassMustBeNotEmpty": "Tên người dùng mới và mật khẩu mới không thể để trống",
|
||||
"getOutboundTrafficError": "Lỗi khi lấy lưu lượng truy cập đi",
|
||||
"resetOutboundTrafficError": "Lỗi khi đặt lại lưu lượng truy cập đi"
|
||||
}
|
||||
},
|
||||
"emailNotifications": "Thông báo",
|
||||
"emailSettings": "Email",
|
||||
"eventCPUHigh": "CPU cao (%)",
|
||||
"eventGroupOutbound": "Outbound",
|
||||
"eventGroupSecurity": "Bảo mật",
|
||||
"eventGroupSystem": "Hệ thống",
|
||||
"eventGroupXray": "Xray Core",
|
||||
"eventLoginAttempt": "Lần thử đăng nhập",
|
||||
"eventOutboundDown": "Ngừng hoạt động",
|
||||
"eventOutboundUp": "Hoạt động",
|
||||
"eventXrayCrash": "Sự cố",
|
||||
"requestFailed": "Yêu cầu thất bại",
|
||||
"smtpEnable": "Bật thông báo qua email",
|
||||
"smtpEnableDesc": "Bật thông báo qua email bằng SMTP",
|
||||
"smtpEncryption": "Mã hóa",
|
||||
"smtpEncryptionDesc": "Phương thức mã hóa kết nối SMTP",
|
||||
"smtpEncryptionNone": "Không (văn bản thuần)",
|
||||
"smtpEncryptionStartTLS": "STARTTLS",
|
||||
"smtpEncryptionTLS": "TLS (ngầm định)",
|
||||
"smtpEventBusNotify": "Thông báo sự kiện qua email",
|
||||
"smtpEventBusNotifyDesc": "Chọn những sự kiện nào sẽ kích hoạt thông báo qua email",
|
||||
"smtpHost": "Máy chủ SMTP",
|
||||
"smtpHostDesc": "Tên máy chủ SMTP (ví dụ: smtp.gmail.com)",
|
||||
"smtpHostNotConfigured": "Chưa cấu hình máy chủ SMTP",
|
||||
"smtpNoRecipients": "Chưa cấu hình người nhận",
|
||||
"smtpNotInitialized": "SMTP chưa được khởi tạo",
|
||||
"smtpPassword": "Mật khẩu SMTP",
|
||||
"smtpPasswordDesc": "Mật khẩu xác thực SMTP",
|
||||
"smtpPort": "Cổng SMTP",
|
||||
"smtpPortDesc": "Cổng máy chủ SMTP (mặc định: 587)",
|
||||
"smtpSettings": "Cài đặt SMTP",
|
||||
"smtpStageAuth": "Xác thực",
|
||||
"smtpStageConnect": "Kết nối",
|
||||
"smtpStageSend": "Gửi",
|
||||
"smtpTestSuccess": "Đã gửi email thử nghiệm thành công",
|
||||
"smtpTo": "Người nhận",
|
||||
"smtpToDesc": "Các địa chỉ email người nhận, phân cách bằng dấu phẩy",
|
||||
"smtpUsername": "Tên đăng nhập SMTP",
|
||||
"smtpUsernameDesc": "Tên đăng nhập xác thực SMTP",
|
||||
"telegramTokenConfigured": "Đã cấu hình; để trống để giữ token hiện tại.",
|
||||
"telegramTokenPlaceholder": "Đã cấu hình - nhập token mới để thay thế",
|
||||
"testSmtp": "Gửi email thử nghiệm",
|
||||
"testTgBot": "Gửi tin nhắn thử nghiệm",
|
||||
"tgBotNotEnabled": "Bot Telegram chưa được bật",
|
||||
"tgBotNotRunning": "Bot Telegram không hoạt động",
|
||||
"tgEventBusNotify": "Thông báo sự kiện qua Telegram",
|
||||
"tgEventBusNotifyDesc": "Chọn những sự kiện nào sẽ kích hoạt thông báo qua Telegram",
|
||||
"tgTestFailed": "Thử nghiệm Telegram thất bại",
|
||||
"tgTestSuccess": "Đã gửi tin nhắn thử nghiệm tới Telegram",
|
||||
"smtpErrorAuth": "Xác thực thất bại — kiểm tra tên đăng nhập và mật khẩu",
|
||||
"smtpErrorStarttls": "Máy chủ yêu cầu STARTTLS — thay đổi kiểu mã hóa",
|
||||
"smtpErrorTls": "Máy chủ yêu cầu TLS — thay đổi kiểu mã hóa",
|
||||
"smtpErrorRefused": "Kết nối bị từ chối — kiểm tra máy chủ và cổng",
|
||||
"smtpErrorTimeout": "Hết thời gian kết nối — không thể truy cập máy chủ",
|
||||
"smtpErrorRelay": "Máy chủ từ chối gửi từ địa chỉ này",
|
||||
"smtpErrorEof": "Kết nối đã bị máy chủ đóng",
|
||||
"smtpErrorUnknown": "Lỗi SMTP: {{ .Error }}",
|
||||
"eventGroupNode": "Node",
|
||||
"eventNodeDown": "Ngừng hoạt động",
|
||||
"eventNodeUp": "Hoạt động",
|
||||
"smtpPasswordConfigured": "Đã cấu hình; để trống để giữ mật khẩu hiện tại.",
|
||||
"smtpPasswordPlaceholder": "Đã cấu hình - nhập mật khẩu mới để thay thế"
|
||||
},
|
||||
"xray": {
|
||||
"title": "Cài đặt Xray",
|
||||
@ -1703,7 +1774,18 @@
|
||||
"AreYouSure": "Bạn có chắc không? 🤔",
|
||||
"SuccessResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Kết quả: ✅ Thành công",
|
||||
"FailedResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Kết quả: ❌ Thất bại \n\n🛠️ Lỗi: [ {{ .ErrorMessage }} ]",
|
||||
"FinishProcess": "🔚 Quá trình đặt lại lưu lượng đã hoàn tất cho tất cả khách hàng."
|
||||
"FinishProcess": "🔚 Quá trình đặt lại lưu lượng đã hoàn tất cho tất cả khách hàng.",
|
||||
"eventCPUHigh": "CPU cao",
|
||||
"eventCPUHighDetail": "CPU: {{ .Detail }}",
|
||||
"eventDelayDetail": "Độ trễ: {{ .Delay }}ms",
|
||||
"eventErrorDetail": "Lỗi: {{ .Error }}",
|
||||
"eventLoginFallback": "Đăng nhập thất bại từ {{ .Source }}",
|
||||
"eventOutboundDown": "Outbound {{ .Tag }} đã NGỪNG HOẠT ĐỘNG",
|
||||
"eventOutboundUp": "Outbound {{ .Tag }} đã HOẠT ĐỘNG",
|
||||
"eventXrayCrash": "Xray GẶP SỰ CỐ",
|
||||
"eventXrayCrashError": "Lỗi: {{ .Error }}",
|
||||
"eventNodeDown": "Node {{ .Name }} đã NGỪNG HOẠT ĐỘNG",
|
||||
"eventNodeUp": "Node {{ .Name }} đã HOẠT ĐỘNG"
|
||||
},
|
||||
"buttons": {
|
||||
"closeKeyboard": "❌ Đóng Bàn Phím",
|
||||
@ -1773,5 +1855,57 @@
|
||||
"chooseClient": "Chọn một Khách hàng cho Inbound {{ .Inbound }}",
|
||||
"chooseInbound": "Chọn một Inbound"
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"labelDelay": "Độ trễ",
|
||||
"labelDetail": "Chi tiết",
|
||||
"labelError": "Lỗi",
|
||||
"labelIP": "IP",
|
||||
"labelOutbound": "Outbound",
|
||||
"labelReason": "Lý do",
|
||||
"labelSource": "Nguồn",
|
||||
"labelStatus": "Trạng thái",
|
||||
"labelTime": "Thời gian",
|
||||
"labelUsername": "Tên đăng nhập",
|
||||
"statusBanned": "BANNED",
|
||||
"statusCrashed": "GẶP SỰ CỐ",
|
||||
"statusDown": "NGỪNG HOẠT ĐỘNG",
|
||||
"statusFailed": "THẤT BẠI",
|
||||
"statusFull": "FULL",
|
||||
"statusHigh": "CAO",
|
||||
"statusOffline": "OFFLINE",
|
||||
"statusOnline": "ONLINE",
|
||||
"statusRunning": "Đang chạy",
|
||||
"statusSuccess": "THÀNH CÔNG",
|
||||
"statusUp": "HOẠT ĐỘNG",
|
||||
"statusXrayDown": "Xray DOWN",
|
||||
"statusXrayUp": "Xray UP",
|
||||
"subjectCPUHigh": "CPU cao",
|
||||
"subjectDiskFull": "Disk full",
|
||||
"subjectIPBanned": "IP banned: {{ .IP }}",
|
||||
"subjectLoginFailed": "Đăng nhập thất bại",
|
||||
"subjectLoginSuccess": "Đăng nhập thành công",
|
||||
"subjectNodeOffline": "Node {{ .Node }} is OFFLINE",
|
||||
"subjectNodeOnline": "Node {{ .Node }} is ONLINE",
|
||||
"subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN",
|
||||
"subjectNodeXrayUp": "Node {{ .Node }} Xray is UP",
|
||||
"subjectOutboundDown": "Outbound {{ .Tag }} đã NGỪNG HOẠT ĐỘNG",
|
||||
"subjectOutboundUp": "Outbound {{ .Tag }} đã HOẠT ĐỘNG",
|
||||
"subjectXrayCrash": "Xray GẶP SỰ CỐ",
|
||||
"subjectXrayUp": "Xray is UP",
|
||||
"titleCPUHigh": "CPU cao",
|
||||
"titleDiskFull": "Disk full",
|
||||
"titleIPBanned": "IP banned",
|
||||
"titleLoginFailed": "Đăng nhập thất bại",
|
||||
"titleLoginSuccess": "Đăng nhập thành công",
|
||||
"titleNodeOffline": "Node OFFLINE",
|
||||
"titleNodeOnline": "Node ONLINE",
|
||||
"titleNodeXrayDown": "Node Xray DOWN",
|
||||
"titleNodeXrayUp": "Node Xray UP",
|
||||
"titleOutboundDown": "Outbound NGỪNG HOẠT ĐỘNG",
|
||||
"titleOutboundUp": "Outbound HOẠT ĐỘNG",
|
||||
"titleXrayCrash": "Xray GẶP SỰ CỐ",
|
||||
"titleXrayUp": "Xray UP",
|
||||
"labelNode": "Node"
|
||||
}
|
||||
}
|
||||
@ -557,6 +557,15 @@
|
||||
"tcpCongestion": "TCP Congestion",
|
||||
"dialerProxy": "Dialer Proxy",
|
||||
"trustedXForwardedFor": "可信 X-Forwarded-For",
|
||||
"trustedXForwardedForHint": "信任此请求头来获取真实客户端 IP(例如 Cloudflare CDN 后的 CF-Connecting-IP)。仅在 WebSocket、HTTPUpgrade 和 XHTTP 传输上生效。留空则忽略转发头。",
|
||||
"proxyProtocolHint": "接受 PROXY protocol 头,从上游 L4 隧道或中继(HAProxy、gost、nginx-stream、Xray dokodemo-door)或 Cloudflare Spectrum 获取真实客户端 IP。上游必须发送 PROXY protocol。适用于 TCP、WebSocket、HTTPUpgrade 和 gRPC;不适用于 mKCP。",
|
||||
"realClientIp": "真实客户端 IP",
|
||||
"realClientIpHint": "当流量通过 CDN 或中继到达此入站时,获取访客的真实 IP,而不是记录中间节点的地址。选择一个预设以自动填写下方对应的 sockopt 字段。这些字段绝不会在订阅中发送给客户端。",
|
||||
"realClientIpPresetOff": "关闭 / 直连",
|
||||
"realClientIpPresetCloudflare": "Cloudflare CDN",
|
||||
"realClientIpPresetProxyProtocol": "L4 中继 / Spectrum (PROXY)",
|
||||
"realClientIpTrustedHeaderTransportWarn": "Trusted X-Forwarded-For 仅在 WebSocket、HTTPUpgrade 和 XHTTP 上生效。在当前传输上此请求头将被忽略。",
|
||||
"realClientIpProxyProtocolTransportWarn": "PROXY protocol 不支持此传输(mKCP)。请使用 TCP/RAW、WebSocket、HTTPUpgrade、gRPC 或 XHTTP。",
|
||||
"addressPortStrategy": "地址+端口策略",
|
||||
"tryDelayMs": "尝试延迟 (ms)",
|
||||
"prioritizeIPv6": "IPv6 优先",
|
||||
@ -1200,7 +1209,69 @@
|
||||
"userPassMustBeNotEmpty": "新用户名和新密码不能为空",
|
||||
"getOutboundTrafficError": "获取出站流量错误",
|
||||
"resetOutboundTrafficError": "重置出站流量错误"
|
||||
}
|
||||
},
|
||||
"emailNotifications": "通知",
|
||||
"emailSettings": "邮件",
|
||||
"eventCPUHigh": "CPU 占用过高(%)",
|
||||
"eventGroupOutbound": "出站",
|
||||
"eventGroupSecurity": "安全",
|
||||
"eventGroupSystem": "系统",
|
||||
"eventGroupXray": "Xray 核心",
|
||||
"eventLoginAttempt": "登录尝试",
|
||||
"eventOutboundDown": "断开",
|
||||
"eventOutboundUp": "恢复",
|
||||
"eventXrayCrash": "崩溃",
|
||||
"requestFailed": "请求失败",
|
||||
"smtpEnable": "启用邮件通知",
|
||||
"smtpEnableDesc": "通过 SMTP 启用邮件通知",
|
||||
"smtpEncryption": "加密",
|
||||
"smtpEncryptionDesc": "SMTP 连接加密方式",
|
||||
"smtpEncryptionNone": "无(明文)",
|
||||
"smtpEncryptionStartTLS": "STARTTLS",
|
||||
"smtpEncryptionTLS": "TLS(隐式)",
|
||||
"smtpEventBusNotify": "邮件事件通知",
|
||||
"smtpEventBusNotifyDesc": "选择触发邮件通知的事件",
|
||||
"smtpHost": "SMTP 主机",
|
||||
"smtpHostDesc": "SMTP 服务器主机名(例如 smtp.gmail.com)",
|
||||
"smtpHostNotConfigured": "尚未配置 SMTP 主机",
|
||||
"smtpNoRecipients": "尚未配置收件人",
|
||||
"smtpNotInitialized": "SMTP 尚未初始化",
|
||||
"smtpPassword": "SMTP 密码",
|
||||
"smtpPasswordDesc": "SMTP 认证密码",
|
||||
"smtpPort": "SMTP 端口",
|
||||
"smtpPortDesc": "SMTP 服务器端口(默认:587)",
|
||||
"smtpSettings": "SMTP 设置",
|
||||
"smtpStageAuth": "认证",
|
||||
"smtpStageConnect": "连接",
|
||||
"smtpStageSend": "发送",
|
||||
"smtpTestSuccess": "测试邮件发送成功",
|
||||
"smtpTo": "收件人",
|
||||
"smtpToDesc": "以逗号分隔的收件人邮箱地址",
|
||||
"smtpUsername": "SMTP 用户名",
|
||||
"smtpUsernameDesc": "SMTP 认证用户名",
|
||||
"telegramTokenConfigured": "已配置;留空则保留当前令牌。",
|
||||
"telegramTokenPlaceholder": "已配置——输入新令牌以替换",
|
||||
"testSmtp": "发送测试邮件",
|
||||
"testTgBot": "发送测试消息",
|
||||
"tgBotNotEnabled": "Telegram 机器人未启用",
|
||||
"tgBotNotRunning": "Telegram 机器人未运行",
|
||||
"tgEventBusNotify": "Telegram 事件通知",
|
||||
"tgEventBusNotifyDesc": "选择触发 Telegram 通知的事件",
|
||||
"tgTestFailed": "Telegram 测试失败",
|
||||
"tgTestSuccess": "测试消息已发送至 Telegram",
|
||||
"smtpErrorAuth": "认证失败——请检查用户名和密码",
|
||||
"smtpErrorStarttls": "服务器要求 STARTTLS——请更改加密类型",
|
||||
"smtpErrorTls": "服务器要求 TLS——请更改加密类型",
|
||||
"smtpErrorRefused": "连接被拒绝——请检查主机和端口",
|
||||
"smtpErrorTimeout": "连接超时——主机无法访问",
|
||||
"smtpErrorRelay": "服务器拒绝从此地址发送",
|
||||
"smtpErrorEof": "连接被服务器关闭",
|
||||
"smtpErrorUnknown": "SMTP 错误:{{ .Error }}",
|
||||
"eventGroupNode": "节点",
|
||||
"eventNodeDown": "离线",
|
||||
"eventNodeUp": "上线",
|
||||
"smtpPasswordConfigured": "已配置;留空则保留当前密码。",
|
||||
"smtpPasswordPlaceholder": "已配置——输入新密码以替换"
|
||||
},
|
||||
"xray": {
|
||||
"title": "Xray 配置",
|
||||
@ -1703,7 +1774,18 @@
|
||||
"AreYouSure": "你确定吗?🤔",
|
||||
"SuccessResetTraffic": "📧 邮箱: {{ .ClientEmail }}\n🏁 结果: ✅ 成功",
|
||||
"FailedResetTraffic": "📧 邮箱: {{ .ClientEmail }}\n🏁 结果: ❌ 失败 \n\n🛠️ 错误: [ {{ .ErrorMessage }} ]",
|
||||
"FinishProcess": "🔚 所有客户的流量重置已完成。"
|
||||
"FinishProcess": "🔚 所有客户的流量重置已完成。",
|
||||
"eventCPUHigh": "CPU 占用过高",
|
||||
"eventCPUHighDetail": "CPU:{{ .Detail }}",
|
||||
"eventDelayDetail": "延迟:{{ .Delay }} 毫秒",
|
||||
"eventErrorDetail": "错误:{{ .Error }}",
|
||||
"eventLoginFallback": "来自 {{ .Source }} 的登录失败",
|
||||
"eventOutboundDown": "出站 {{ .Tag }} 已断开",
|
||||
"eventOutboundUp": "出站 {{ .Tag }} 已恢复",
|
||||
"eventXrayCrash": "Xray 已崩溃",
|
||||
"eventXrayCrashError": "错误:{{ .Error }}",
|
||||
"eventNodeDown": "节点 {{ .Name }} 已离线",
|
||||
"eventNodeUp": "节点 {{ .Name }} 已上线"
|
||||
},
|
||||
"buttons": {
|
||||
"closeKeyboard": "❌ 关闭键盘",
|
||||
@ -1773,5 +1855,57 @@
|
||||
"chooseClient": "为入站 {{ .Inbound }} 选择一个客户",
|
||||
"chooseInbound": "选择一个入站"
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"labelDelay": "延迟",
|
||||
"labelDetail": "详情",
|
||||
"labelError": "错误",
|
||||
"labelIP": "IP",
|
||||
"labelOutbound": "出站",
|
||||
"labelReason": "原因",
|
||||
"labelSource": "来源",
|
||||
"labelStatus": "状态",
|
||||
"labelTime": "时间",
|
||||
"labelUsername": "用户名",
|
||||
"statusBanned": "BANNED",
|
||||
"statusCrashed": "已崩溃",
|
||||
"statusDown": "断开",
|
||||
"statusFailed": "失败",
|
||||
"statusFull": "FULL",
|
||||
"statusHigh": "过高",
|
||||
"statusOffline": "OFFLINE",
|
||||
"statusOnline": "ONLINE",
|
||||
"statusRunning": "运行中",
|
||||
"statusSuccess": "成功",
|
||||
"statusUp": "恢复",
|
||||
"statusXrayDown": "Xray DOWN",
|
||||
"statusXrayUp": "Xray UP",
|
||||
"subjectCPUHigh": "CPU 占用过高",
|
||||
"subjectDiskFull": "Disk full",
|
||||
"subjectIPBanned": "IP banned: {{ .IP }}",
|
||||
"subjectLoginFailed": "登录失败",
|
||||
"subjectLoginSuccess": "登录成功",
|
||||
"subjectNodeOffline": "Node {{ .Node }} is OFFLINE",
|
||||
"subjectNodeOnline": "Node {{ .Node }} is ONLINE",
|
||||
"subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN",
|
||||
"subjectNodeXrayUp": "Node {{ .Node }} Xray is UP",
|
||||
"subjectOutboundDown": "出站 {{ .Tag }} 已断开",
|
||||
"subjectOutboundUp": "出站 {{ .Tag }} 已恢复",
|
||||
"subjectXrayCrash": "Xray 已崩溃",
|
||||
"subjectXrayUp": "Xray is UP",
|
||||
"titleCPUHigh": "CPU 占用过高",
|
||||
"titleDiskFull": "Disk full",
|
||||
"titleIPBanned": "IP banned",
|
||||
"titleLoginFailed": "登录失败",
|
||||
"titleLoginSuccess": "登录成功",
|
||||
"titleNodeOffline": "Node OFFLINE",
|
||||
"titleNodeOnline": "Node ONLINE",
|
||||
"titleNodeXrayDown": "Node Xray DOWN",
|
||||
"titleNodeXrayUp": "Node Xray UP",
|
||||
"titleOutboundDown": "出站断开",
|
||||
"titleOutboundUp": "出站恢复",
|
||||
"titleXrayCrash": "Xray 已崩溃",
|
||||
"titleXrayUp": "Xray UP",
|
||||
"labelNode": "节点"
|
||||
}
|
||||
}
|
||||
@ -557,6 +557,15 @@
|
||||
"tcpCongestion": "TCP Congestion",
|
||||
"dialerProxy": "Dialer Proxy",
|
||||
"trustedXForwardedFor": "信任的 X-Forwarded-For",
|
||||
"trustedXForwardedForHint": "信任此請求標頭以取得真實用戶端 IP(例如 Cloudflare CDN 後的 CF-Connecting-IP)。僅在 WebSocket、HTTPUpgrade 和 XHTTP 傳輸上生效。留空則忽略轉發標頭。",
|
||||
"proxyProtocolHint": "接受 PROXY protocol 標頭,從上游 L4 隧道或中繼(HAProxy、gost、nginx-stream、Xray dokodemo-door)或 Cloudflare Spectrum 取得真實用戶端 IP。上游必須傳送 PROXY protocol。適用於 TCP、WebSocket、HTTPUpgrade 和 gRPC;不適用於 mKCP。",
|
||||
"realClientIp": "真實用戶端 IP",
|
||||
"realClientIpHint": "當流量透過 CDN 或中繼到達此入站時,取得訪客的真實 IP,而非記錄中間節點的位址。選擇一個預設以自動填入下方對應的 sockopt 欄位。這些欄位絕不會在訂閱中傳送給用戶端。",
|
||||
"realClientIpPresetOff": "關閉 / 直連",
|
||||
"realClientIpPresetCloudflare": "Cloudflare CDN",
|
||||
"realClientIpPresetProxyProtocol": "L4 中繼 / Spectrum (PROXY)",
|
||||
"realClientIpTrustedHeaderTransportWarn": "Trusted X-Forwarded-For 僅在 WebSocket、HTTPUpgrade 和 XHTTP 上生效。在目前的傳輸上此標頭將被忽略。",
|
||||
"realClientIpProxyProtocolTransportWarn": "PROXY protocol 不支援此傳輸(mKCP)。請使用 TCP/RAW、WebSocket、HTTPUpgrade、gRPC 或 XHTTP。",
|
||||
"addressPortStrategy": "地址+連接埠策略",
|
||||
"tryDelayMs": "嘗試延遲 (ms)",
|
||||
"prioritizeIPv6": "IPv6 優先",
|
||||
@ -1200,7 +1209,69 @@
|
||||
"userPassMustBeNotEmpty": "新使用者名稱和新密碼不能為空",
|
||||
"getOutboundTrafficError": "取得出站流量錯誤",
|
||||
"resetOutboundTrafficError": "重設出站流量錯誤"
|
||||
}
|
||||
},
|
||||
"emailNotifications": "通知",
|
||||
"emailSettings": "電子郵件",
|
||||
"eventCPUHigh": "CPU 偏高(%)",
|
||||
"eventGroupOutbound": "出站",
|
||||
"eventGroupSecurity": "安全性",
|
||||
"eventGroupSystem": "系統",
|
||||
"eventGroupXray": "Xray 核心",
|
||||
"eventLoginAttempt": "登入嘗試",
|
||||
"eventOutboundDown": "中斷",
|
||||
"eventOutboundUp": "恢復",
|
||||
"eventXrayCrash": "當機",
|
||||
"requestFailed": "請求失敗",
|
||||
"smtpEnable": "啟用電子郵件通知",
|
||||
"smtpEnableDesc": "透過 SMTP 啟用電子郵件通知",
|
||||
"smtpEncryption": "加密",
|
||||
"smtpEncryptionDesc": "SMTP 連線加密方式",
|
||||
"smtpEncryptionNone": "無(純文字)",
|
||||
"smtpEncryptionStartTLS": "STARTTLS",
|
||||
"smtpEncryptionTLS": "TLS(隱含)",
|
||||
"smtpEventBusNotify": "電子郵件事件通知",
|
||||
"smtpEventBusNotifyDesc": "選擇觸發電子郵件通知的事件",
|
||||
"smtpHost": "SMTP 主機",
|
||||
"smtpHostDesc": "SMTP 伺服器主機名稱(例如 smtp.gmail.com)",
|
||||
"smtpHostNotConfigured": "尚未設定 SMTP 主機",
|
||||
"smtpNoRecipients": "尚未設定收件人",
|
||||
"smtpNotInitialized": "SMTP 尚未初始化",
|
||||
"smtpPassword": "SMTP 密碼",
|
||||
"smtpPasswordDesc": "SMTP 驗證密碼",
|
||||
"smtpPort": "SMTP 連接埠",
|
||||
"smtpPortDesc": "SMTP 伺服器連接埠(預設:587)",
|
||||
"smtpSettings": "SMTP 設定",
|
||||
"smtpStageAuth": "驗證",
|
||||
"smtpStageConnect": "連線",
|
||||
"smtpStageSend": "傳送",
|
||||
"smtpTestSuccess": "測試郵件已成功傳送",
|
||||
"smtpTo": "收件人",
|
||||
"smtpToDesc": "以逗號分隔的收件人電子郵件地址",
|
||||
"smtpUsername": "SMTP 使用者名稱",
|
||||
"smtpUsernameDesc": "SMTP 驗證使用者名稱",
|
||||
"telegramTokenConfigured": "已設定;留空以保留目前的權杖。",
|
||||
"telegramTokenPlaceholder": "已設定 - 輸入新權杖以取代",
|
||||
"testSmtp": "傳送測試郵件",
|
||||
"testTgBot": "傳送測試訊息",
|
||||
"tgBotNotEnabled": "Telegram 機器人未啟用",
|
||||
"tgBotNotRunning": "Telegram 機器人未執行",
|
||||
"tgEventBusNotify": "Telegram 事件通知",
|
||||
"tgEventBusNotifyDesc": "選擇觸發 Telegram 通知的事件",
|
||||
"tgTestFailed": "Telegram 測試失敗",
|
||||
"tgTestSuccess": "測試訊息已傳送至 Telegram",
|
||||
"smtpErrorAuth": "驗證失敗 — 請檢查使用者名稱和密碼",
|
||||
"smtpErrorStarttls": "伺服器需要 STARTTLS — 請變更加密類型",
|
||||
"smtpErrorTls": "伺服器需要 TLS — 請變更加密類型",
|
||||
"smtpErrorRefused": "連線遭拒 — 請檢查主機和連接埠",
|
||||
"smtpErrorTimeout": "連線逾時 — 無法連線至主機",
|
||||
"smtpErrorRelay": "伺服器拒絕從此地址傳送",
|
||||
"smtpErrorEof": "連線已被伺服器關閉",
|
||||
"smtpErrorUnknown": "SMTP 錯誤:{{ .Error }}",
|
||||
"eventGroupNode": "節點",
|
||||
"eventNodeDown": "離線",
|
||||
"eventNodeUp": "上線",
|
||||
"smtpPasswordConfigured": "已設定;留空以保留目前的密碼。",
|
||||
"smtpPasswordPlaceholder": "已設定 - 輸入新密碼以取代"
|
||||
},
|
||||
"xray": {
|
||||
"title": "Xray 配置",
|
||||
@ -1703,7 +1774,18 @@
|
||||
"AreYouSure": "你確定嗎?🤔",
|
||||
"SuccessResetTraffic": "📧 電子郵件: {{ .ClientEmail }}\n🏁 結果: ✅ 成功",
|
||||
"FailedResetTraffic": "📧 電子郵件: {{ .ClientEmail }}\n🏁 結果: ❌ 失敗 \n\n🛠️ 錯誤: [ {{ .ErrorMessage }} ]",
|
||||
"FinishProcess": "🔚 所有客戶的流量重置已完成。"
|
||||
"FinishProcess": "🔚 所有客戶的流量重置已完成。",
|
||||
"eventCPUHigh": "CPU 偏高",
|
||||
"eventCPUHighDetail": "CPU:{{ .Detail }}",
|
||||
"eventDelayDetail": "延遲:{{ .Delay }} 毫秒",
|
||||
"eventErrorDetail": "錯誤:{{ .Error }}",
|
||||
"eventLoginFallback": "來自 {{ .Source }} 的登入失敗",
|
||||
"eventOutboundDown": "出站 {{ .Tag }} 已中斷",
|
||||
"eventOutboundUp": "出站 {{ .Tag }} 已恢復",
|
||||
"eventXrayCrash": "Xray 已當機",
|
||||
"eventXrayCrashError": "錯誤:{{ .Error }}",
|
||||
"eventNodeDown": "節點 {{ .Name }} 已離線",
|
||||
"eventNodeUp": "節點 {{ .Name }} 已上線"
|
||||
},
|
||||
"buttons": {
|
||||
"closeKeyboard": "❌ 關閉鍵盤",
|
||||
@ -1773,5 +1855,57 @@
|
||||
"chooseClient": "為入站 {{ .Inbound }} 選擇一個客戶",
|
||||
"chooseInbound": "選擇一個入站"
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"labelDelay": "延遲",
|
||||
"labelDetail": "詳細資訊",
|
||||
"labelError": "錯誤",
|
||||
"labelIP": "IP",
|
||||
"labelOutbound": "出站",
|
||||
"labelReason": "原因",
|
||||
"labelSource": "來源",
|
||||
"labelStatus": "狀態",
|
||||
"labelTime": "時間",
|
||||
"labelUsername": "使用者名稱",
|
||||
"statusBanned": "BANNED",
|
||||
"statusCrashed": "已當機",
|
||||
"statusDown": "中斷",
|
||||
"statusFailed": "失敗",
|
||||
"statusFull": "FULL",
|
||||
"statusHigh": "偏高",
|
||||
"statusOffline": "OFFLINE",
|
||||
"statusOnline": "ONLINE",
|
||||
"statusRunning": "執行中",
|
||||
"statusSuccess": "成功",
|
||||
"statusUp": "恢復",
|
||||
"statusXrayDown": "Xray DOWN",
|
||||
"statusXrayUp": "Xray UP",
|
||||
"subjectCPUHigh": "CPU 偏高",
|
||||
"subjectDiskFull": "Disk full",
|
||||
"subjectIPBanned": "IP banned: {{ .IP }}",
|
||||
"subjectLoginFailed": "登入失敗",
|
||||
"subjectLoginSuccess": "登入成功",
|
||||
"subjectNodeOffline": "Node {{ .Node }} is OFFLINE",
|
||||
"subjectNodeOnline": "Node {{ .Node }} is ONLINE",
|
||||
"subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN",
|
||||
"subjectNodeXrayUp": "Node {{ .Node }} Xray is UP",
|
||||
"subjectOutboundDown": "出站 {{ .Tag }} 已中斷",
|
||||
"subjectOutboundUp": "出站 {{ .Tag }} 已恢復",
|
||||
"subjectXrayCrash": "Xray 已當機",
|
||||
"subjectXrayUp": "Xray is UP",
|
||||
"titleCPUHigh": "CPU 偏高",
|
||||
"titleDiskFull": "Disk full",
|
||||
"titleIPBanned": "IP banned",
|
||||
"titleLoginFailed": "登入失敗",
|
||||
"titleLoginSuccess": "登入成功",
|
||||
"titleNodeOffline": "Node OFFLINE",
|
||||
"titleNodeOnline": "Node ONLINE",
|
||||
"titleNodeXrayDown": "Node Xray DOWN",
|
||||
"titleNodeXrayUp": "Node Xray UP",
|
||||
"titleOutboundDown": "出站中斷",
|
||||
"titleOutboundUp": "出站恢復",
|
||||
"titleXrayCrash": "Xray 已當機",
|
||||
"titleXrayUp": "Xray UP",
|
||||
"labelNode": "節點"
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net"
|
||||
@ -16,6 +17,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/mtproto"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
@ -26,9 +28,11 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/network"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/email"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
|
||||
"github.com/gin-contrib/gzip"
|
||||
"github.com/gin-contrib/sessions"
|
||||
@ -112,6 +116,7 @@ type Server struct {
|
||||
|
||||
wsHub *websocket.Hub
|
||||
|
||||
bus *eventbus.Bus
|
||||
cron *cron.Cron
|
||||
|
||||
ctx context.Context
|
||||
@ -277,7 +282,9 @@ const (
|
||||
cadenceNodeTraffic = "@every 5s"
|
||||
cadenceOutboundSub = "@every 5m"
|
||||
cadenceCheckHash = "@every 2m"
|
||||
cadenceCPUAlarm = "@every 10s"
|
||||
// cpu.Percent samples over a full minute (blocking), so a finer cadence just
|
||||
// stacks overlapping samplers; subscribers rate-limit alerts to 1/min anyway.
|
||||
cadenceCPUAlarm = "@every 1m"
|
||||
)
|
||||
|
||||
// startTask schedules background jobs (Xray checks, traffic jobs, cron
|
||||
@ -347,8 +354,7 @@ func (s *Server) startTask(restartXray bool) {
|
||||
s.cron.AddJob(runtime, j)
|
||||
}
|
||||
|
||||
// Make a traffic condition every day, 8:30
|
||||
var entry cron.EntryID
|
||||
// Telegram-bot–dependent jobs: periodic stats report + callback-hash cleanup.
|
||||
isTgbotenabled, err := s.settingService.GetTgbotEnabled()
|
||||
if (err == nil) && (isTgbotenabled) {
|
||||
runtime, err := s.settingService.GetTgbotRuntime()
|
||||
@ -360,23 +366,50 @@ func (s *Server) startTask(restartXray bool) {
|
||||
runtime = "@daily"
|
||||
}
|
||||
logger.Infof("Tg notify enabled,run at %s", runtime)
|
||||
_, err = s.cron.AddJob(runtime, job.NewStatsNotifyJob())
|
||||
if err != nil {
|
||||
if _, err = s.cron.AddJob(runtime, job.NewStatsNotifyJob()); err != nil {
|
||||
logger.Warningf("Add NewStatsNotifyJob: failed to schedule runtime %q: %v", runtime, err)
|
||||
return
|
||||
}
|
||||
|
||||
// check for Telegram bot callback query hash storage reset
|
||||
s.cron.AddJob(cadenceCheckHash, job.NewCheckHashStorageJob())
|
||||
|
||||
// Check CPU load and alarm to TgBot if threshold passes
|
||||
cpuThreshold, err := s.settingService.GetTgCpu()
|
||||
if (err == nil) && (cpuThreshold > 0) {
|
||||
s.cron.AddJob(cadenceCPUAlarm, job.NewCheckCpuJob())
|
||||
}
|
||||
} else {
|
||||
s.cron.Remove(entry)
|
||||
}
|
||||
|
||||
// CPU monitor publishes cpu.high events; register it whenever any notifier
|
||||
// (Telegram or Email) wants them, independent of the Telegram bot being on.
|
||||
if s.cpuAlarmWanted() {
|
||||
s.cron.AddJob(cadenceCPUAlarm, job.NewCheckCpuJob())
|
||||
}
|
||||
}
|
||||
|
||||
// cpuAlarmWanted reports whether any notifier is configured to receive cpu.high
|
||||
// alerts, so the minute-long blocking CPU sampler only runs when it's needed.
|
||||
func (s *Server) cpuAlarmWanted() bool {
|
||||
wants := func(events string, threshold int) bool {
|
||||
if threshold <= 0 {
|
||||
return false
|
||||
}
|
||||
for _, e := range strings.Split(events, ",") {
|
||||
if strings.TrimSpace(e) == string(eventbus.EventCPUHigh) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if on, _ := s.settingService.GetTgbotEnabled(); on {
|
||||
events, _ := s.settingService.GetTgEnabledEvents()
|
||||
cpu, _ := s.settingService.GetTgCpu()
|
||||
if wants(events, cpu) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if on, _ := s.settingService.GetSmtpEnable(); on {
|
||||
events, _ := s.settingService.GetSmtpEnabledEvents()
|
||||
cpu, _ := s.settingService.GetSmtpCpu()
|
||||
if wants(events, cpu) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Start initializes and starts the web server with configured settings, routes, and background jobs.
|
||||
@ -479,6 +512,42 @@ func (s *Server) start(restartXray bool, startTgBot bool) (err error) {
|
||||
s.httpServer.Serve(listener)
|
||||
}()
|
||||
|
||||
// Create event bus before startTask so jobs can use it
|
||||
s.bus = eventbus.New(eventbus.DefaultBufferSize)
|
||||
service.SetEventBus(s.bus)
|
||||
job.EventBus = s.bus
|
||||
tgbot.EventBus = s.bus
|
||||
|
||||
// Wire xray crash callback BEFORE startTask so it's ready
|
||||
xray.OnCrash = func(err error) {
|
||||
if s.bus != nil {
|
||||
s.bus.Publish(eventbus.Event{
|
||||
Type: eventbus.EventXrayCrash,
|
||||
Data: err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Register email subscriber (always — it checks smtpEnable at runtime)
|
||||
emailService := email.NewEmailService(s.settingService)
|
||||
emailSub := email.NewSubscriber(s.settingService, emailService)
|
||||
s.bus.Subscribe("email-notifier", emailSub.HandleEvent)
|
||||
|
||||
// Wire email service to controller for test endpoint
|
||||
controller.SetEmailService(emailService)
|
||||
|
||||
// Wire Telegram test function to controller
|
||||
controller.SetTestTgFunc(func() error {
|
||||
if !s.tgbotService.IsRunning() {
|
||||
return fmt.Errorf("telegram bot is not running (check token and chat ID)")
|
||||
}
|
||||
if err := s.tgbotService.TestConnection(); err != nil {
|
||||
return fmt.Errorf("telegram API test failed: %w", err)
|
||||
}
|
||||
s.tgbotService.SendMsgToTgbotAdmins("✅ Test message from 3x-ui")
|
||||
return nil
|
||||
})
|
||||
|
||||
s.startTask(restartXray)
|
||||
|
||||
if startTgBot {
|
||||
@ -486,6 +555,8 @@ func (s *Server) start(restartXray bool, startTgBot bool) (err error) {
|
||||
if (err == nil) && (isTgbotenabled) {
|
||||
tgBot := s.tgbotService.NewTgbot()
|
||||
tgBot.Start(i18nFS)
|
||||
// Subscribe Telegram notifications for event bus
|
||||
s.bus.Subscribe("tg-notifier", s.tgbotService.HandleEvent)
|
||||
}
|
||||
}
|
||||
|
||||
@ -510,6 +581,9 @@ func (s *Server) stop(stopXray bool, stopTgBot bool) error {
|
||||
if s.cron != nil {
|
||||
s.cron.Stop()
|
||||
}
|
||||
if s.bus != nil {
|
||||
s.bus.Stop()
|
||||
}
|
||||
if err := service.PersistSystemMetrics(); err != nil {
|
||||
logger.Warning("persist system metrics on shutdown failed:", err)
|
||||
}
|
||||
|
||||
@ -213,6 +213,8 @@ func (p *process) SetOnlineAPISupport(v OnlineAPISupport) {
|
||||
var (
|
||||
xrayGracefulStopTimeout = 5 * time.Second
|
||||
xrayForceStopTimeout = 2 * time.Second
|
||||
// OnCrash is called when xray crashes unexpectedly. Set from web layer.
|
||||
OnCrash func(err error)
|
||||
)
|
||||
|
||||
// newProcess creates a new internal process struct for Xray.
|
||||
@ -566,6 +568,9 @@ func (p *process) waitForCommand(cmd *exec.Cmd) {
|
||||
|
||||
logger.Error("Failure in running xray-core:", err)
|
||||
p.exitErr = err
|
||||
if OnCrash != nil {
|
||||
OnCrash(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Stop terminates the running Xray process.
|
||||
|
||||
Reference in New Issue
Block a user