Compare commits

...

8 Commits

Author SHA1 Message Date
f3eba04ed8 ci: use .nvmrc for setup-node version in codeql/release workflows 2026-06-15 23:50:05 +02:00
9385b6c609 feat(nodes): per-node client IP attribution for IP-limit
Record each panel's own Xray IP observations under its panelGuid and merge each node's guid-keyed report on the master, so the panel can tell which node a client IP is connecting through (the flat inbound_client_ips union is pushed back to every node and cannot attribute). Adds the NodeClientIp model + migration, the clientIpsByGuid endpoint and node-sync merge, node-name labels in the client IP log, and cleanup on node deletion.
2026-06-15 23:50:05 +02:00
d882d6aa74 feat(inbounds): add Real client IP presets to capture visitor IP behind CDN/relay
Surface the existing sockopt knobs (acceptProxyProtocol, trustedXForwardedFor) as a guided 'Real client IP' preset selector in the inbound form, so the real visitor IP is recovered behind Cloudflare CDN or an L4 tunnel/relay instead of recording the intermediary address. Presets are mutually exclusive, warn on incompatible transports, and add tooltips, docs, and translations for all locales.
2026-06-15 23:50:04 +02:00
bbab83db17 refactor(frontend): stack client credential fields and use label hints on inbound form
Stack UUID/password/subId/auth/flow/security fields vertically in the client modal instead of two-column rows, and replace the inbound form's 'extra' help lines with hover tooltip hints on field labels.
2026-06-15 21:38:11 +02:00
dc781b28c4 chore(deps): bump telego to v1.10.0 2026-06-15 21:15:38 +02:00
5b8504c756 chore(deps): bump frontend deps and override js-yaml to patch DoS advisory
Force swagger-ui-react's bundled js-yaml to ^4.2.0 (GHSA-h67p-54hq-rp68)
without downgrading swagger-ui-react. Also picks up minor bumps to antd,
axios, react-router-dom and dev deps.
2026-06-15 21:15:38 +02:00
c1fdcd98d2 fix(nodes): route 'load inbounds' through the connection outbound
Loading a node's inbound list bypassed the configured connection
outbound and dialed the remote panel directly, so a node only reachable
through that outbound timed out with 'context deadline exceeded' even
though Test Connection succeeded.

Extract the temporary loopback SOCKS5 bridge setup from ProbeWithOutbound
into a shared withOutboundBridge helper and route GetRemoteInboundOptions
through it when an outbound tag is set.
2026-06-15 21:13:27 +02:00
eec030f86f feat(notifications): event bus architecture with Telegram and SMTP subscribers (#5326)
* feat(notifications): event bus architecture with Telegram and SMTP subscribers

- Event bus core with buffered channel, fan-out, panic recovery
- Telegram subscriber with HTML formatting and rate limiting
- Email subscriber with SMTP/TLS/STARTTLS support and stage diagnostics
- 5 event types: outbound.down/up, xray.crash, cpu.high, login.attempt
- CPU threshold checks per subscriber (tgCpu for TG, smtpCpu for Email)
- SystemMetricData struct for raw metric values in events
- i18n keys for en-US, ru-RU, and English defaults for other locales

* fix

* fix(notifications): repair crash/CPU alerts, harden secrets, add node alerts

Bug fixes:
- Xray crash notifications were permanently suppressed after the first crash:
  XrayStateTracker latched state="down" with no reset and no recovery event,
  so only the first crash per process lifetime ever notified. Removed the
  tracker; the existing 1/min rate limiter already dedupes crash-loop spam.
- Email CPU alerts could never fire unless Telegram was also enabled, because
  the CPU job was registered only inside the tgbot block. Register it whenever
  either Telegram or SMTP wants cpu.high (new cpuAlarmWanted gate) and relax
  the cadence to @every 1m (cpu.Percent already samples over a full minute).
- SMTP password (and, pre-existing, all other secrets) were shipped to the
  browser in plaintext: GetAllSettingView was dead code and /setting/all
  returned the raw model. Wire getAllSetting -> GetAllSettingView, redact
  smtpPassword with a hasSmtpPassword presence flag, and preserve it on blank
  save. Closes the leak for tgBotToken/ldapPassword/2FA token too.

Polish:
- email Send: use nil SMTP auth when no credentials (Go refuses PlainAuth over
  the unencrypted "none" transport).
- Remove unused EventClientDepleted; fix inaccurate bus.go doc comments; drop
  stale tgBotLoginNotify from the frontend schema; gofmt alignment.

Feature - node online/offline alerts:
- Emit node.down/node.up from the heartbeat job on a real status transition
  (with a startup-spam guard), reusing NodeHealthData. Formatted by both the
  Telegram and email subscribers and selectable in the settings UI.

Regenerated frontend types (hasSmtpPassword). New i18n keys added to en-US;
other locales fall back to English (bundle default) until translated.

* fix(settings): use antd Space orientation instead of deprecated direction

Ant Design 6 deprecated Space's `direction` prop in favor of `orientation`,
which logged a console warning from the Telegram/Email notification tabs. Brings
these two tabs in line with the rest of the codebase, which already uses
`orientation`.

* i18n(notifications): translate the notification feature into all locales

The notifications PR shipped ~99 new strings (SMTP settings, event labels,
Telegram/email message templates) as English placeholders in every non-English
locale. Translate them — plus the node-alert keys added during this review —
into all 12 locales: Arabic, Spanish, Persian, Indonesian, Japanese,
Portuguese-BR, Russian, Turkish, Ukrainian, Vietnamese, and Simplified/
Traditional Chinese.

Go-template placeholders ({{ .Tag }}, {{ .Name }}, etc.) are preserved exactly;
tgbot message values carry no leading status emoji (the bot/email code adds
those, so an emoji in the value would duplicate it); product/protocol names
(SMTP, STARTTLS, TLS, CPU, Xray, Telegram) are kept as-is.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-06-15 21:03:41 +02:00
72 changed files with 5109 additions and 440 deletions

View File

@ -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

View File

@ -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
View 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.

View File

@ -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"

View File

@ -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": {

View File

@ -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": [

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

View File

@ -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';

View File

@ -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": "",

View File

@ -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",

View File

@ -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;

View File

@ -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(),

View File

@ -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) {

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

View File

@ -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) {

View File

@ -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',

View File

@ -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>

View File

@ -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>

View File

@ -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. */}

View File

@ -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"

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

View File

@ -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} />;

View File

@ -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>
</>
),

View File

@ -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>;

View File

@ -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",

View File

@ -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
View File

@ -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
View File

@ -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=

View File

@ -73,6 +73,7 @@ func initModels() error {
&model.ClientGroup{},
&model.InboundFallback{},
&model.NodeClientTraffic{},
&model.NodeClientIp{},
&model.ClientGlobalTraffic{},
&model.OutboundSubscription{},
}

View File

@ -50,6 +50,7 @@ func migrationModels() []any {
&model.ClientExternalLink{},
&model.InboundFallback{},
&model.NodeClientTraffic{},
&model.NodeClientIp{},
&model.OutboundSubscription{},
}
}

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

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

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

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

View File

@ -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) {

View File

@ -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 }

View File

@ -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.

View File

@ -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

View File

@ -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],
},
})
}
}

View File

@ -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

View File

@ -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,
},
})
}

View File

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

View File

@ -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
}

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

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

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

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

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

View File

@ -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) {

View File

@ -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
}

View File

@ -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) {

View File

@ -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

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

View File

@ -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.

View File

@ -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
}

View File

@ -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))

View File

@ -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": "العقدة"
}
}

View File

@ -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"
}
}
}

View File

@ -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"
}
}

View File

@ -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": "نود"
}
}
}

View File

@ -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"
}
}

View File

@ -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": "ノード"
}
}

View File

@ -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ó"
}
}

View File

@ -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": "Узел"
}
}

View File

@ -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"
}
}

View File

@ -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": "Вузол"
}
}

View File

@ -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"
}
}

View File

@ -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": "节点"
}
}

View File

@ -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": "節點"
}
}

View File

@ -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-botdependent 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)
}

View File

@ -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.