Compare commits
13
Commits
v3.7.0
...
1498d58f14
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1498d58f14 | ||
|
|
981ba8abe1 | ||
|
|
7ece6e079b | ||
|
|
f9b0672d84 | ||
|
|
2c0d9c1209 | ||
|
|
57474e0700 | ||
|
|
bcd7b47c3d | ||
|
|
9105f7f364 | ||
|
|
6381587307 | ||
|
|
fc59cb1032 | ||
|
|
2f2efdac42 | ||
|
|
1701eb24e2 | ||
|
|
0ba1ec0596 |
@@ -0,0 +1,46 @@
|
||||
# 3x-ui 路由器主控精简版 — 定版清单
|
||||
|
||||
> 2026-10-20 与用户逐项讨论中。本文档定版前不开工。
|
||||
> 背景:AX6600 路由器 = **主控**(3x-ui 3.7.0,纯控制面,本机 xray 已 chmod -x 禁用,RSS ~130MB);
|
||||
> Alpine 主控 = 备用,趋于退役。精简目标:二开构建压内存,验收线 RSS ≤ 90MB。
|
||||
|
||||
## 保留(已定)
|
||||
|
||||
- 入站/客户端管理、节点同步、内部流量统计(主控本职)
|
||||
- 订阅服务,但**极简输出**:只给节点本身,不塞分流规则模板(geosite/geoip 规则集去掉);多格式转换保留
|
||||
- VLESS、SS2022 入站(SS2022 为全网硬约束,不得移除)
|
||||
- **API 令牌系统(暂保留)**:存量原版节点与主控的同步靠原版令牌机制(远程面板令牌 + node-sync scoped token);二开 agent 走自己的注册令牌是另一套。等存量节点全换二开 agent 后再议删除
|
||||
- 数据库 schema 不动(回滚保险:换回原版二进制可读同一份库)
|
||||
|
||||
## 裁剪(已定)
|
||||
|
||||
- Telegram 机器人 + 面板设置里的「通知」(用户改手动备份;路由器上的 TG 备份 cron 绕行方案同步弃用)
|
||||
- xray 配置模块**部分裁**:**出站 + 路由保留**(页面与生成逻辑都要;节点内网中转靠主控下发出站/路由,用户明确);其余模板部件(DNS、日志、策略等本机向设置)固定为内置默认值,去掉页面与生成/校验逻辑
|
||||
- xray 可执行文件不再随包(x25519 内置生成后无硬依赖)
|
||||
- **xray-core 库剥离**:配置生成改为纯 JSON 结构实现。最大一刀、风险最高,**最后动**
|
||||
- API 文档(Swagger 页)
|
||||
- 面板设置:外部流量(对外上报接口,未接任何外部系统;内部统计不受影响)
|
||||
- 面板设置:LDAP
|
||||
- 安全设置:双重验证(2FA,用户不用)
|
||||
- 邮件模块(SMTP 发送:到期/流量提醒等,用户不用)
|
||||
- 分组功能(客户端归类标签 + 按组批量操作/聚合订阅;用户从未用过,裁功能不影响已建客户端与订阅)
|
||||
|
||||
## 待用户确认
|
||||
|
||||
- 协议范围:是否只留 VLESS + SS2022,裁其余协议模板(VMess、Trojan、WireGuard、TUIC、Hysteria、MTProto 等)——建议裁
|
||||
- IP 检查 / 测速类后台任务——建议裁
|
||||
- 面板内置 ACME 签证书(走 CF Tunnel,不直签)——建议裁
|
||||
- 多用户功能(单管理员使用)——建议裁
|
||||
|
||||
## 修复(非裁剪,已定)
|
||||
|
||||
- **x25519 内置生成**:原版生成 Reality 密钥靠调 `xray x25519`,主控禁用 xray 后建 VLESS+Reality 节点报"获取 x25519 证书错误"。改面板 Go 内置实现(crypto/ecdh),不再依赖 xray 二进制
|
||||
- **mixed 协议节点部署 bug**(原版 bug):主控建入站选 mixed 协议时节点选择框消失,只能建本机;但节点本地建的 mixed 入站可同步回主控管理,证明后端/同步通道支持,纯前端对话框条件问题。修前端条件,允许 mixed 选节点部署
|
||||
|
||||
## 流程与验收(已定)
|
||||
|
||||
- 基线:3.7.0 二开版,不升级版本
|
||||
- 顺序:边角裁剪 → 两个 bug 修复 → 最后剥 xray-core
|
||||
- 交付:沙箱构建 → 路由器旁路端口冒烟 → 用户验收后替换;原版二进制保留,换回即回滚
|
||||
- 验收线:RSS ≤ 90MB;订阅可拉;节点同步正常;Reality 建节点不报错(含 x25519 获取);mixed 可部署到节点;流量统计正常
|
||||
- 内存预估(2026-10-20):基线 130.6MB → 预计 50-70MB。边角裁剪(TG/邮件/LDAP/2FA/文档/分组)对 RSS 贡献仅个位数 MB(多为休眠代码,主要瘦二进制);大头在剥 xray-core(库初始化/注册表/API 客户端栈)。若 xray-core 那刀不动,改完仍在 110-120MB。下限参考:框架(Gin+GORM+SQLite+Web UI+节点同步+订阅渲染)底盘约 40-50MB,到不了 NexusGate Go 版的 15-30MB(那是 stdlib 重写的量级)
|
||||
@@ -1,206 +1,56 @@
|
||||
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md) | [Türkçe](/README.tr_TR.md)
|
||||
# 3x-ui-agent
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./media/3x-ui-dark.png">
|
||||
<img alt="3x-ui" src="./media/3x-ui-light.png">
|
||||
</picture>
|
||||
</p>
|
||||
基于 3x-ui v3.7.0 的二次开发:节点 agent 化。节点鸡不再跑完整面板,只跑一个轻量 agent(约 60MB 内存),由主控统一管理、下发 xray 配置。
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/MHSanaei/3x-ui/releases"><img src="https://img.shields.io/github/v/release/mhsanaei/3x-ui" alt="Release"></a>
|
||||
<a href="https://github.com/MHSanaei/3x-ui/actions"><img src="https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg" alt="Build"></a>
|
||||
<a href="#"><img src="https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg" alt="GO Version"></a>
|
||||
<a href="https://github.com/MHSanaei/3x-ui/releases/latest"><img src="https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg" alt="Downloads"></a>
|
||||
<a href="https://www.gnu.org/licenses/gpl-3.0.en.html"><img src="https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true" alt="License"></a>
|
||||
<a href="https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3"><img src="https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg" alt="Go Reference"></a>
|
||||
</p>
|
||||
## 架构
|
||||
|
||||
**3X-UI** is an advanced, open-source web control panel for managing [Xray-core](https://github.com/XTLS/Xray-core) servers. It provides a clean, multi-language interface for deploying, configuring, and monitoring a wide range of proxy and VPN protocols — from a single VPS to multi-node deployments.
|
||||
- **主控**:完整 3x-ui 面板,唯一的配置可信源。管理节点、入站、客户端,下发完整 xray 配置(含出站、路由)到各 agent。
|
||||
- **Agent**:跑在节点鸡上,通过 token 向主控注册。定时拉取主控下发的 xray 配置并应用,上报心跳和流量。只提供 VLESS / SS2022 入站。
|
||||
|
||||
Built as an enhanced fork of the original X-UI project, 3X-UI adds broader protocol support, improved stability, per-client traffic accounting, and many quality-of-life features.
|
||||
## 安装
|
||||
|
||||
> [!IMPORTANT]
|
||||
> This project is intended for personal use only. Please do not use it for illegal purposes or in a production environment.
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-protocol inbounds** — VLESS, VMess, Trojan, Shadowsocks, WireGuard, Hysteria2, HTTP, SOCKS (Mixed), Dokodemo-door / Tunnel, and TUN.
|
||||
- **Modern transports & security** — TCP (Raw), mKCP, WebSocket, gRPC, HTTPUpgrade, and XHTTP, secured with TLS, XTLS, and REALITY.
|
||||
- **Fallbacks** — serve multiple protocols on a single port (e.g. VLESS and Trojan on 443) using Xray's fallback support.
|
||||
- **Per-client management** — traffic quotas, expiry dates, IP limits, live online status, and one-click share links, QR codes, and subscriptions.
|
||||
- **Traffic statistics** — per inbound, per client, and per outbound, with reset controls.
|
||||
- **Multi-node support** — manage and scale across multiple servers from a single panel.
|
||||
- **Outbound & routing** — WARP, NordVPN, custom routing rules, load balancers, and outbound proxy chaining.
|
||||
- **Built-in subscription server** with multiple output formats and [custom page templates](docs/custom-subscription-templates.md).
|
||||
- **Telegram bot** for remote monitoring and management.
|
||||
- **RESTful API** with in-panel Swagger documentation.
|
||||
- **Flexible storage** — SQLite (default) or PostgreSQL.
|
||||
- **13 UI languages** with dark and light themes.
|
||||
- **Fail2ban integration** for enforcing per-client IP limits.
|
||||
|
||||
## Screenshots
|
||||
|
||||
<details>
|
||||
<summary>Click to expand</summary>
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./media/01-overview-dark.png">
|
||||
<img alt="Overview" src="./media/01-overview-light.png">
|
||||
</picture>
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./media/02-add-inbound-dark.png">
|
||||
<img alt="Inbounds" src="./media/02-add-inbound-light.png">
|
||||
</picture>
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./media/03-add-client-dark.png">
|
||||
<img alt="Add client" src="./media/03-add-client-light.png">
|
||||
</picture>
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./media/05-add-nodes-dark.png">
|
||||
<img alt="Configs" src="./media/05-add-nodes-light.png">
|
||||
</picture>
|
||||
|
||||
</details>
|
||||
|
||||
## Quick Start
|
||||
### 主控
|
||||
|
||||
```bash
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
|
||||
bash <(curl -fL https://raw.githubusercontent.com/aaaamoooo/3x-ui-diy/main/install-master.sh)
|
||||
```
|
||||
|
||||
To install a specific version, append its tag (e.g. `v3.4.0`):
|
||||
装完面板在 `http://主控IP:2053`,默认账号 `admin` / `admin`。
|
||||
|
||||
### Agent(节点鸡)
|
||||
|
||||
先在主控面板「节点管理」→「添加节点」→ 类型选 **Agent**,保存后弹窗会显示 token,复制下来。
|
||||
|
||||
然后在节点鸡上:
|
||||
|
||||
```bash
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) v3.4.0
|
||||
bash <(curl -fL https://raw.githubusercontent.com/aaaamoooo/3x-ui-diy/main/install-agent.sh) \
|
||||
--master http://主控IP:2053 --token 你的TOKEN --name 节点名
|
||||
```
|
||||
|
||||
To install the rolling **dev** build (latest per-commit pre-release from `main`, not a stable release), pass `dev-latest`:
|
||||
agent 约 30 秒后拉到配置并启动 xray,节点在面板显示在线。
|
||||
|
||||
```bash
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh) dev-latest
|
||||
```
|
||||
## 使用流程
|
||||
|
||||
During installation a random username, password, and access path are generated. After installation, run `x-ui` to open the management menu, where you can start/stop the service, view or reset your login credentials, manage SSL certificates, and more.
|
||||
1. 主控面板添加 Agent 类型节点,拿到 token
|
||||
2. 节点鸡一键安装 agent,填入主控地址和 token
|
||||
3. 在「入站列表」创建入站(VLESS / SS2022),「部署到」选择在线的 agent 节点
|
||||
4. 主控自动把完整 xray 配置下发到节点,客户端即可连接
|
||||
|
||||
For full documentation, please visit the [project Wiki](https://github.com/MHSanaei/3x-ui/wiki).
|
||||
## 内存占用(实测)
|
||||
|
||||
### Unattended install
|
||||
| | 内存 |
|
||||
|---|---|
|
||||
| agent + xray | 约 63MB |
|
||||
| 完整 3x-ui 面板 | 130–160MB |
|
||||
|
||||
The installer also runs **non-interactively** for cloud-init.
|
||||
Set `XUI_NONINTERACTIVE=1` (or pipe with no TTY) and it installs end-to-end with
|
||||
zero prompts, generating random credentials and writing them to
|
||||
`/etc/x-ui/install-result.env`. See [`deploy/`](deploy/) for:
|
||||
## 与原版的区别
|
||||
|
||||
- [Cloud-init user-data](deploy/cloud-init/) — unattended install on any cloud (Hetzner/AWS/DO/Vultr/GCP/Azure/Oracle)
|
||||
- [Hetzner Cloud notes](deploy/marketplace/hetzner/) — cloud-init deployment on Hetzner
|
||||
- 节点不再需要安装完整面板
|
||||
- 主控是唯一可信源,节点配置全部由主控下发
|
||||
- 节点在线状态由 agent 心跳维持
|
||||
- 添加节点支持 Agent 类型,自动生成注册 token
|
||||
|
||||
## Supported Platforms
|
||||
## License
|
||||
|
||||
**Operating systems:** Ubuntu, Debian, Armbian, Fedora, CentOS, RHEL, AlmaLinux, Rocky Linux, Oracle Linux, Amazon Linux, Virtuozzo, Arch, Manjaro, Parch, openSUSE (Tumbleweed / Leap), Alpine, and Windows.
|
||||
|
||||
**Architectures:** `amd64` · `386` · `arm64` (aarch64) · `armv7` · `armv6` · `armv5` · `s390x`.
|
||||
|
||||
## Database Options
|
||||
|
||||
3X-UI supports two backends, chosen during the install:
|
||||
|
||||
- **SQLite** (default) — a single file at `/etc/x-ui/x-ui.db`. Zero setup, ideal for small and medium deployments.
|
||||
- **PostgreSQL** — recommended for high client counts or multi-node setups. The installer can install PostgreSQL locally for you, or accept a DSN to an existing server.
|
||||
|
||||
At runtime the backend is selected via environment variables (the installer writes these to `/etc/default/x-ui` for you):
|
||||
|
||||
```
|
||||
XUI_DB_TYPE=postgres
|
||||
XUI_DB_DSN=postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable
|
||||
```
|
||||
|
||||
### Migrating an existing SQLite install to PostgreSQL
|
||||
|
||||
```bash
|
||||
x-ui migrate-db --dsn "postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable"
|
||||
# then set XUI_DB_TYPE and XUI_DB_DSN in /etc/default/x-ui and restart:
|
||||
systemctl restart x-ui
|
||||
```
|
||||
|
||||
The source SQLite file is left untouched; remove it manually once you have verified the new backend.
|
||||
|
||||
### Docker
|
||||
|
||||
The default `docker compose up -d` keeps using SQLite. To run with the bundled PostgreSQL service, uncomment the two `XUI_DB_*` env lines in `docker-compose.yml` and start with the profile:
|
||||
|
||||
```bash
|
||||
docker compose --profile postgres up -d
|
||||
```
|
||||
|
||||
The image bundles Fail2ban (enabled by default) to enforce per-client **IP limits**. Fail2ban bans offenders with `iptables`, which requires the `NET_ADMIN` capability. `docker-compose.yml` already grants it via `cap_add`; if you start the container with `docker run` instead, add the capabilities yourself, otherwise bans are logged but never applied:
|
||||
|
||||
```bash
|
||||
docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
| --- | --- | --- |
|
||||
| `XUI_DB_TYPE` | Database backend: `sqlite` or `postgres` | `sqlite` |
|
||||
| `XUI_DB_DSN` | PostgreSQL connection string (when `XUI_DB_TYPE=postgres`) | — |
|
||||
| `XUI_DB_FOLDER` | Directory for the SQLite database file | `/etc/x-ui` |
|
||||
| `XUI_DB_MAX_OPEN_CONNS` | Maximum open connections (PostgreSQL pool) | — |
|
||||
| `XUI_DB_MAX_IDLE_CONNS` | Maximum idle connections (PostgreSQL pool) | — |
|
||||
| `XUI_INIT_WEB_BASE_PATH` | The initial URI path for the web panel | `/` |
|
||||
| `XUI_ENABLE_FAIL2BAN` | Enable Fail2ban-based IP-limit enforcement | `true` |
|
||||
| `XUI_LOG_LEVEL` | Log verbosity (`debug`, `info`, `warning`, `error`) | `info` |
|
||||
| `XUI_DEBUG` | Enable debug mode | `false` |
|
||||
| `XUI_TUNNEL_HEALTH_MONITOR` | Enable the tunnel health monitor (probes a URL and restarts xray after repeated failures; a restart drops all clients) | `false` |
|
||||
| `XUI_TUNNEL_HEALTH_PROXY` | Proxy the probe is sent through; point it at a local xray inbound so the probe tests the tunnel (e.g. `socks5://127.0.0.1:1080`). Empty means the probe only checks host connectivity | — |
|
||||
| `XUI_TUNNEL_HEALTH_URL` | URL probed for tunnel health | `https://www.cloudflare.com/cdn-cgi/trace` |
|
||||
| `XUI_TUNNEL_HEALTH_INTERVAL` | Interval between probes | `30s` |
|
||||
| `XUI_TUNNEL_HEALTH_TIMEOUT` | Per-probe timeout | `10s` |
|
||||
| `XUI_TUNNEL_HEALTH_FAILURES` | Consecutive failures before a restart is triggered | `3` |
|
||||
| `XUI_TUNNEL_HEALTH_COOLDOWN` | Minimum delay between consecutive restarts | `5m` |
|
||||
|
||||
## Supported Languages
|
||||
|
||||
The panel UI is available in 13 languages:
|
||||
|
||||
English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil)
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome. Please read the [Contributing Guide](/CONTRIBUTING.md) before opening an issue or pull request.
|
||||
|
||||
## A Special Thanks to
|
||||
|
||||
- [alireza0](https://github.com/alireza0/)
|
||||
|
||||
## Acknowledgment
|
||||
|
||||
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (License: **GPL-3.0**): _Enhanced v2ray/xray and v2ray/xray-clients routing rules with built-in Iranian domains and a focus on security and adblocking._
|
||||
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (License: **GPL-3.0**): _This repository contains automatically updated V2Ray routing rules based on data on blocked domains and addresses in Russia._
|
||||
|
||||
## Community Tools
|
||||
|
||||
Tools and integrations built by the community around 3x-ui.
|
||||
|
||||
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (License: **MIT**): _Manage inbounds, clients, panel settings, and Xray configuration as code with Terraform / OpenTofu._
|
||||
|
||||
## Support project
|
||||
|
||||
**If this project is helpful to you, you may wish to give it a**:star2:
|
||||
|
||||
<a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
|
||||
<img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
|
||||
</a>
|
||||
|
||||
</br>
|
||||
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
|
||||
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
|
||||
</a>
|
||||
|
||||
## Stargazers over Time
|
||||
|
||||
[](https://starchart.cc/MHSanaei/3x-ui)
|
||||
GPL-3.0(继承上游 3x-ui)
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
# 3x-ui Agent 模式安装说明
|
||||
|
||||
节点鸡不再跑完整 3x-ui 面板,只跑一个轻量 agent:用 token 向主控注册,
|
||||
主控下发完整 xray 配置(含入站、出站、路由规则),agent 写配置并管理 xray 进程,
|
||||
每 60 秒向主控上报流量/在线数。
|
||||
|
||||
## 架构
|
||||
|
||||
- 主控:完整 3x-ui(v3.7.0+agent 补丁),负责生成配置、下发、管理出站与路由规则。
|
||||
- 节点:`x-ui agent` 二进制 + xray 二进制 + geo 文件,无面板、无数据库。
|
||||
- 出站/路由规则只能在主控上建和改(面板是唯一可信源),agent 只管应用下发
|
||||
的完整配置,不上报本地配置。
|
||||
|
||||
## 一、主控准备:生成 agent token
|
||||
|
||||
先在主控的「节点管理」里建好(或复用)该节点,记下节点 id。然后用面板
|
||||
API token 调用(面板 → 设置 → API Token 新建一个):
|
||||
|
||||
```bash
|
||||
# 生成 token,绑定到节点 id=3
|
||||
curl -sk -H "Authorization: Bearer <PANEL_API_TOKEN>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"node_id":3,"name":"hk-node-1"}' \
|
||||
https://主控地址:2053/panel/api/agent/tokens
|
||||
# 返回 {"success":true,"obj":{"id":1,"token":"...","token_prefix":"...","node_id":3}}
|
||||
```
|
||||
|
||||
`token` 只返回一次,请保存好。查看已发 token(不含明文):
|
||||
|
||||
```bash
|
||||
curl -sk -H "Authorization: Bearer <PANEL_API_TOKEN>" \
|
||||
https://主控地址:2053/panel/api/agent/tokens
|
||||
```
|
||||
|
||||
## 二、节点安装
|
||||
|
||||
```bash
|
||||
mkdir -p /usr/local/x-ui/bin /etc/x-ui
|
||||
|
||||
# 1. 放 x-ui 二进制(agent 补丁版,与主控同版本)
|
||||
cp x-ui /usr/local/x-ui/x-ui && chmod +x /usr/local/x-ui/x-ui
|
||||
|
||||
# 2. 放 xray 二进制与 geo 文件(agent 不负责下载,手动放置)
|
||||
cp xray-linux-amd64 /usr/local/x-ui/bin/xray && chmod +x /usr/local/x-ui/bin/xray
|
||||
cp geoip.dat geosite.dat /usr/local/x-ui/bin/
|
||||
|
||||
# 3. 写 agent 配置
|
||||
cat > /etc/x-ui/agent.json <<'EOF'
|
||||
{
|
||||
"master_url": "http://主控内网地址:2053",
|
||||
"token": "<上一步生成的 token>",
|
||||
"node_name": "hk-node-1"
|
||||
}
|
||||
EOF
|
||||
chmod 600 /etc/x-ui/agent.json
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `master_url` 用 `http://`(内网)或 `https://`(需可信证书;自签证书连不上,
|
||||
agent 默认校验 TLS)。
|
||||
- `poll_interval` 可省略,默认 30 秒轮询一次配置。
|
||||
- 不要在节点上再启动完整面板(`x-ui` 不带 `agent` 参数的模式)。
|
||||
|
||||
## 三、启动 agent
|
||||
|
||||
systemd(Debian/Ubuntu):
|
||||
|
||||
```bash
|
||||
cp systemd/x-ui-agent.service /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now x-ui-agent
|
||||
journalctl -u x-ui-agent -f
|
||||
```
|
||||
|
||||
OpenWrt(procd):
|
||||
|
||||
```bash
|
||||
cp openrc/x-ui-agent /etc/init.d/x-ui-agent
|
||||
chmod +x /etc/init.d/x-ui-agent
|
||||
/etc/init.d/x-ui-agent enable
|
||||
/etc/init.d/x-ui-agent start
|
||||
logread -f | grep x-ui-agent
|
||||
```
|
||||
|
||||
## 四、主控管理该节点的出站与路由规则
|
||||
|
||||
出站/路由规则按 agent 存(节点特异的内网地址如 `10.240.64.2:23183`
|
||||
必须按节点配,不能做全局模板)。`<TOKEN>` 用第一步生成的完整 token:
|
||||
|
||||
```bash
|
||||
# 查看当前 egress(未设置时返回空数组,走模板默认 direct/blocked)
|
||||
curl -sk -H "Authorization: Bearer <PANEL_API_TOKEN>" \
|
||||
https://主控地址:2053/panel/api/agent/egress/<TOKEN>
|
||||
|
||||
# 写入出站+路由规则(完整数组,直接替换模板默认)
|
||||
curl -sk -H "Authorization: Bearer <PANEL_API_TOKEN>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"outbounds":[{"tag":"PPFL3-L1","protocol":"socks","settings":{"servers":[{"address":"10.240.64.2","port":23183}]}}],
|
||||
"routing_rules":[{"type":"field","inboundTag":["in-18692-tcp"],"outboundTag":"PPFL3-L1"}]}' \
|
||||
-X PUT https://主控地址:2053/panel/api/agent/egress/<TOKEN>
|
||||
```
|
||||
|
||||
- 下发配置里的 stats API 路由规则(`inboundTag: ["api"]`)会自动补齐,
|
||||
不用手写,写了也不会重复。
|
||||
- 传空数组会清空该项、回退到模板默认。
|
||||
|
||||
## 五、验证
|
||||
|
||||
1. agent 日志出现 `Xray config applied successfully`。
|
||||
2. 节点上 `ss -tlnp | grep xray` 能看到入站端口在监听。
|
||||
3. 主控 `GET /panel/api/agent/tokens` 里该 token 的 `last_seen` 在更新。
|
||||
4. 客户端用入站链接连通;约 60 秒后主控面板上该节点入站/用户的流量开始涨。
|
||||
5. 吊销 token:`curl -sk -H "Authorization: Bearer <PANEL_API_TOKEN>" -X DELETE https://主控地址:2053/panel/api/agent/tokens/<id>`,
|
||||
agent 下次心跳/取配置会收到 401 并在日志报错。
|
||||
|
||||
## 故障排查
|
||||
|
||||
| 现象 | 查 |
|
||||
|---|---|
|
||||
| `register failed ... 401` | token 贴错,或 token 没在主控生成 |
|
||||
| `config fetch failed ... 409` | token 未绑定节点(生成时没传 `node_id`) |
|
||||
| xray 起不来 | `/usr/local/x-ui/bin/xray` 是否存在可执行;`xray -test -c /usr/local/x-ui/bin/config.json` 看配置报错 |
|
||||
| 流量不上报 | xray 的 api 入站端口是否被占用;agent 日志看 `Traffic report failed` |
|
||||
| 主控连不上 | `master_url` 拼错;https 自签证书改走 `http://` 内网地址 |
|
||||
Executable
+83
@@ -0,0 +1,83 @@
|
||||
#!/bin/bash
|
||||
# 3x-ui-agent 节点一键安装脚本
|
||||
# 用法: bash install-agent.sh --master http://MASTER_IP:2053 --token YOUR_AGENT_TOKEN [--name my-node]
|
||||
# 要求: Debian/Ubuntu/Alpine/CentOS (amd64/arm64), root 运行
|
||||
|
||||
set -e
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; NC='\033[0m'
|
||||
info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||
error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
|
||||
|
||||
[[ $EUID -ne 0 ]] && error "请用 root 运行"
|
||||
|
||||
MASTER=""; TOKEN=""; NAME="agent-node"
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--master) MASTER="$2"; shift 2;;
|
||||
--token) TOKEN="$2"; shift 2;;
|
||||
--name) NAME="$2"; shift 2;;
|
||||
*) error "未知参数: $1";;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$MASTER" ]] && error "缺少 --master 参数"
|
||||
[[ -z "$TOKEN" ]] && error "缺少 --token 参数 (从主控面板 API 获取)"
|
||||
|
||||
ARCH=$(uname -m)
|
||||
case "$ARCH" in
|
||||
x86_64|amd64) BIN_ARCH="amd64";;
|
||||
aarch64|arm64) BIN_ARCH="arm64";;
|
||||
*) error "不支持的架构: $ARCH (仅支持 amd64/arm64)";;
|
||||
esac
|
||||
|
||||
# 二进制下载地址(GitHub release,替换为实际地址)
|
||||
XUI_URL="${XUI_URL:-https://github.com/aaaamoooo/3x-ui-diy/releases/download/v3.7.0-agent.1/x-ui-linux-${BIN_ARCH}}"
|
||||
XRAY_VER="26.6.27"
|
||||
|
||||
info "创建目录..."
|
||||
mkdir -p /usr/local/x-ui/bin /etc/x-ui
|
||||
|
||||
info "下载 agent 二进制..."
|
||||
curl -fL -o /usr/local/x-ui/x-ui "$XUI_URL" || error "agent 下载失败"
|
||||
chmod +x /usr/local/x-ui/x-ui
|
||||
|
||||
info "下载 xray v${XRAY_VER}..."
|
||||
cd /usr/local/x-ui/bin
|
||||
case "$BIN_ARCH" in
|
||||
amd64) XRAY_ZIP="Xray-linux-64.zip";;
|
||||
arm64) XRAY_ZIP="Xray-linux-arm64-v8a.zip";;
|
||||
esac
|
||||
curl -fL -o "$XRAY_ZIP" "https://github.com/XTLS/Xray-core/releases/download/v${XRAY_VER}/${XRAY_ZIP}" || error "xray 下载失败"
|
||||
unzip -o -q "$XRAY_ZIP" xray && mv xray "xray-linux-${BIN_ARCH}" && chmod +x "xray-linux-${BIN_ARCH}"
|
||||
rm -f "$XRAY_ZIP"
|
||||
|
||||
info "下载 geo 文件..."
|
||||
curl -fL -o geoip.dat "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat" || error "geoip 下载失败"
|
||||
curl -fL -o geosite.dat "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat" || error "geosite 下载失败"
|
||||
|
||||
info "写入 agent 配置..."
|
||||
cat > /etc/x-ui/agent.json <<EOF
|
||||
{
|
||||
"master_url": "$MASTER",
|
||||
"token": "$TOKEN",
|
||||
"node_name": "$NAME"
|
||||
}
|
||||
EOF
|
||||
chmod 600 /etc/x-ui/agent.json
|
||||
|
||||
info "启动 agent..."
|
||||
pkill -f "/usr/local/x-ui/x-ui agent" 2>/dev/null; sleep 1
|
||||
nohup /usr/local/x-ui/x-ui agent > /tmp/agent.log 2>&1 &
|
||||
sleep 8
|
||||
|
||||
if grep -q "Starting 3x-ui agent" /tmp/agent.log 2>/dev/null; then
|
||||
info "agent 启动成功!"
|
||||
echo ""
|
||||
echo " 主控: $MASTER"
|
||||
echo " 节点名: $NAME"
|
||||
echo " 日志: tail -f /tmp/agent.log"
|
||||
echo " agent 会在 30 秒内从主控拉取配置"
|
||||
else
|
||||
error "启动失败,查看日志: cat /tmp/agent.log"
|
||||
fi
|
||||
Executable
+60
@@ -0,0 +1,60 @@
|
||||
#!/bin/bash
|
||||
# 3x-ui-agent 主控一键安装脚本
|
||||
# 用法: bash install-master.sh
|
||||
# 要求: Debian/Ubuntu/Alpine/CentOS (amd64/arm64), root 运行
|
||||
|
||||
set -e
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; NC='\033[0m'
|
||||
info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||
error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
|
||||
|
||||
[[ $EUID -ne 0 ]] && error "请用 root 运行"
|
||||
|
||||
ARCH=$(uname -m)
|
||||
case "$ARCH" in
|
||||
x86_64|amd64) BIN_ARCH="amd64";;
|
||||
aarch64|arm64) BIN_ARCH="arm64";;
|
||||
*) error "不支持的架构: $ARCH (仅支持 amd64/arm64)";;
|
||||
esac
|
||||
|
||||
# 二进制下载地址(GitHub release,替换为实际地址)
|
||||
XUI_URL="${XUI_URL:-https://github.com/aaaamoooo/3x-ui-diy/releases/download/v3.7.0-agent.1/x-ui-linux-${BIN_ARCH}}"
|
||||
XRAY_VER="26.6.27"
|
||||
|
||||
info "创建目录..."
|
||||
mkdir -p /usr/local/x-ui/bin /etc/x-ui
|
||||
|
||||
info "下载主控二进制..."
|
||||
curl -fL -o /usr/local/x-ui/x-ui "$XUI_URL" || error "主控下载失败"
|
||||
chmod +x /usr/local/x-ui/x-ui
|
||||
|
||||
info "下载 xray v${XRAY_VER}..."
|
||||
cd /usr/local/x-ui/bin
|
||||
case "$BIN_ARCH" in
|
||||
amd64) XRAY_ZIP="Xray-linux-64.zip";;
|
||||
arm64) XRAY_ZIP="Xray-linux-arm64-v8a.zip";;
|
||||
esac
|
||||
curl -fL -o "$XRAY_ZIP" "https://github.com/XTLS/Xray-core/releases/download/v${XRAY_VER}/${XRAY_ZIP}" || error "xray 下载失败"
|
||||
unzip -o -q "$XRAY_ZIP" xray && mv xray "xray-linux-${BIN_ARCH}" && chmod +x "xray-linux-${BIN_ARCH}"
|
||||
rm -f "$XRAY_ZIP"
|
||||
|
||||
info "下载 geo 文件..."
|
||||
curl -fL -o geoip.dat "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat" || error "geoip 下载失败"
|
||||
curl -fL -o geosite.dat "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat" || error "geosite 下载失败"
|
||||
|
||||
info "启动主控..."
|
||||
cd /usr/local/x-ui
|
||||
pkill -f "/usr/local/x-ui/x-ui" 2>/dev/null; sleep 1
|
||||
nohup ./x-ui > /tmp/xui.log 2>&1 &
|
||||
sleep 5
|
||||
|
||||
if ss -tlnp 2>/dev/null | grep -q ":2053"; then
|
||||
info "主控启动成功!"
|
||||
echo ""
|
||||
echo " 面板地址: http://$(hostname -I | awk '{print $1}'):2053"
|
||||
echo " 默认账号: admin / admin"
|
||||
echo " 请登录后立即修改密码"
|
||||
else
|
||||
error "启动失败,查看日志: cat /tmp/xui.log"
|
||||
fi
|
||||
@@ -0,0 +1,407 @@
|
||||
// Package agent implements the lightweight 3x-ui node agent.
|
||||
// In agent mode, the node does not run the web panel or database.
|
||||
// It registers with the master via token, receives xray config, manages the
|
||||
// xray process, and reports traffic back to the master.
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
const (
|
||||
// Default poll interval for config updates
|
||||
defaultPollInterval = 30 * time.Second
|
||||
// Heartbeat interval
|
||||
defaultHeartbeatInterval = 60 * time.Second
|
||||
// Traffic report interval (piggybacks the heartbeat tick)
|
||||
defaultTrafficInterval = 60 * time.Second
|
||||
)
|
||||
|
||||
// DefaultBinFolder is used when XUI_BIN_FOLDER is unset. It matches the
|
||||
// panel's directory layout: xray binary, geo files and config.json live here.
|
||||
const DefaultBinFolder = "/usr/local/x-ui/bin"
|
||||
|
||||
// Config holds the agent configuration
|
||||
type Config struct {
|
||||
MasterURL string `json:"master_url"`
|
||||
Token string `json:"token"`
|
||||
NodeName string `json:"node_name"`
|
||||
PollInterval time.Duration `json:"poll_interval"`
|
||||
// ApiPort overrides the xray stats API inbound port from the master's
|
||||
// config. Needed when agent and master share a host (e.g. testing),
|
||||
// otherwise both xrays fight over the default 62789. 0 = use master's.
|
||||
ApiPort int `json:"api_port"`
|
||||
}
|
||||
|
||||
// Agent represents the node agent
|
||||
type Agent struct {
|
||||
config *Config
|
||||
httpClient *http.Client
|
||||
stopCh chan struct{}
|
||||
lastConfigHash string
|
||||
xrayProc *xray.Process
|
||||
xrayAPI xray.XrayAPI
|
||||
apiPort int
|
||||
}
|
||||
|
||||
// New creates a new agent instance
|
||||
func New(cfg *Config) *Agent {
|
||||
if cfg.PollInterval == 0 {
|
||||
cfg.PollInterval = defaultPollInterval
|
||||
}
|
||||
return &Agent{
|
||||
config: cfg,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: false},
|
||||
},
|
||||
},
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Register registers the agent with the master
|
||||
func (a *Agent) Register() error {
|
||||
url := fmt.Sprintf("%s/panel/api/agent/register", a.config.MasterURL)
|
||||
payload := map[string]string{
|
||||
"token": a.config.Token,
|
||||
"name": a.config.NodeName,
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+a.config.Token)
|
||||
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("register failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("register failed with status %d: %s", resp.StatusCode, string(data))
|
||||
}
|
||||
|
||||
logger.Info("Agent registered successfully with master")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run starts the agent main loop
|
||||
func (a *Agent) Run() error {
|
||||
logger.Info("Starting 3x-ui agent...")
|
||||
logger.Infof("Master: %s, Node: %s", a.config.MasterURL, a.config.NodeName)
|
||||
|
||||
// Initial registration
|
||||
if err := a.Register(); err != nil {
|
||||
return fmt.Errorf("initial registration failed: %w", err)
|
||||
}
|
||||
|
||||
// Start heartbeat ticker
|
||||
heartbeatTicker := time.NewTicker(defaultHeartbeatInterval)
|
||||
defer heartbeatTicker.Stop()
|
||||
|
||||
// Start config poll ticker
|
||||
pollTicker := time.NewTicker(a.config.PollInterval)
|
||||
defer pollTicker.Stop()
|
||||
|
||||
// Start traffic report ticker
|
||||
trafficTicker := time.NewTicker(defaultTrafficInterval)
|
||||
defer trafficTicker.Stop()
|
||||
|
||||
// Initial config fetch
|
||||
if err := a.fetchAndApplyConfig(); err != nil {
|
||||
logger.Warningf("Initial config fetch failed: %v", err)
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-a.stopCh:
|
||||
logger.Info("Agent stopping...")
|
||||
a.stopXray()
|
||||
return nil
|
||||
case <-heartbeatTicker.C:
|
||||
if err := a.sendHeartbeat(); err != nil {
|
||||
logger.Warningf("Heartbeat failed: %v", err)
|
||||
}
|
||||
case <-pollTicker.C:
|
||||
if err := a.fetchAndApplyConfig(); err != nil {
|
||||
logger.Warningf("Config fetch failed: %v", err)
|
||||
}
|
||||
case <-trafficTicker.C:
|
||||
if err := a.collectAndReportTraffic(); err != nil {
|
||||
logger.Warningf("Traffic report failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop stops the agent
|
||||
func (a *Agent) Stop() {
|
||||
close(a.stopCh)
|
||||
}
|
||||
|
||||
// sendHeartbeat sends a heartbeat to the master
|
||||
func (a *Agent) sendHeartbeat() error {
|
||||
url := fmt.Sprintf("%s/panel/api/agent/heartbeat", a.config.MasterURL)
|
||||
payload := map[string]interface{}{
|
||||
"token": a.config.Token,
|
||||
"timestamp": time.Now().Unix(),
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+a.config.Token)
|
||||
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("heartbeat failed with status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchAndApplyConfig fetches the xray config from master and applies it if changed
|
||||
func (a *Agent) fetchAndApplyConfig() error {
|
||||
url := fmt.Sprintf("%s/panel/api/agent/config", a.config.MasterURL)
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+a.config.Token)
|
||||
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("config fetch failed with status %d: %s", resp.StatusCode, string(data))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
ConfigHash string `json:"config_hash"`
|
||||
XrayConfig json.RawMessage `json:"xray_config"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Skip if config hasn't changed
|
||||
if result.ConfigHash == a.lastConfigHash && result.ConfigHash != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Info("New xray config received, applying...")
|
||||
if err := a.applyXrayConfig(result.XrayConfig); err != nil {
|
||||
return fmt.Errorf("failed to apply xray config: %w", err)
|
||||
}
|
||||
|
||||
a.lastConfigHash = result.ConfigHash
|
||||
logger.Info("Xray config applied successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureXrayBinary makes sure the xray binary exists at the panel-layout path.
|
||||
// The install doc puts it at /usr/local/x-ui/bin/xray; the panel's process
|
||||
// manager looks for the arch-specific name, so link one to the other.
|
||||
func ensureXrayBinary() error {
|
||||
binPath := xray.GetBinaryPath()
|
||||
if _, err := os.Stat(binPath); err == nil {
|
||||
return nil
|
||||
}
|
||||
plain := filepath.Join(config.GetBinFolderPath(), "xray")
|
||||
if _, err := os.Stat(plain); err != nil {
|
||||
return fmt.Errorf("xray binary not found at %s or %s", plain, binPath)
|
||||
}
|
||||
// Best effort: link the arch-specific name the process manager expects.
|
||||
if err := os.Symlink(plain, binPath); err != nil && !os.IsExist(err) {
|
||||
return fmt.Errorf("failed to link xray binary: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyXrayConfig writes the xray config and (re)starts xray through the
|
||||
// panel's process manager.
|
||||
func (a *Agent) applyXrayConfig(configJSON json.RawMessage) error {
|
||||
if err := ensureXrayBinary(); err != nil {
|
||||
return err
|
||||
}
|
||||
var cfg xray.Config
|
||||
if err := json.Unmarshal(configJSON, &cfg); err != nil {
|
||||
return fmt.Errorf("invalid xray config: %w", err)
|
||||
}
|
||||
// Same-host override: rewrite the stats API inbound port so the agent's
|
||||
// xray doesn't fight the master's xray over the default port.
|
||||
if a.config.ApiPort > 0 {
|
||||
for i := range cfg.InboundConfigs {
|
||||
if cfg.InboundConfigs[i].Tag == "api" {
|
||||
logger.Infof("Overriding xray API port %d -> %d (agent config)",
|
||||
cfg.InboundConfigs[i].Port, a.config.ApiPort)
|
||||
cfg.InboundConfigs[i].Port = a.config.ApiPort
|
||||
}
|
||||
}
|
||||
// Also move the prometheus metrics listener (default 127.0.0.1:11111)
|
||||
// which would collide with the master's xray on the same host.
|
||||
if len(cfg.Metrics) > 0 {
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(cfg.Metrics, &m); err == nil {
|
||||
if ls, ok := m["listen"].(string); ok && ls != "" {
|
||||
newListen := fmt.Sprintf("127.0.0.1:%d", a.config.ApiPort+1)
|
||||
logger.Infof("Overriding xray metrics listen %s -> %s (agent config)", ls, newListen)
|
||||
m["listen"] = newListen
|
||||
if raw, err := json.Marshal(m); err == nil {
|
||||
cfg.Metrics = raw
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
a.apiPort = apiPortFromConfig(&cfg)
|
||||
|
||||
a.stopXray()
|
||||
|
||||
// Use a dedicated config file so the agent's xray doesn't fight the
|
||||
// master's xray over the shared config.json when both run on one host.
|
||||
agentConfigPath := config.GetBinFolderPath() + "/config-agent.json"
|
||||
proc := xray.NewTestProcess(&cfg, agentConfigPath)
|
||||
if err := proc.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start xray: %w", err)
|
||||
}
|
||||
a.xrayProc = proc
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Agent) stopXray() {
|
||||
if a.xrayProc != nil {
|
||||
_ = a.xrayProc.Stop()
|
||||
a.xrayProc = nil
|
||||
}
|
||||
}
|
||||
|
||||
// apiPortFromConfig extracts the stats API port (the inbound tagged "api").
|
||||
func apiPortFromConfig(cfg *xray.Config) int {
|
||||
for _, inbound := range cfg.InboundConfigs {
|
||||
if inbound.Tag == "api" {
|
||||
return inbound.Port
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type trafficReport struct {
|
||||
Inbounds []trafficEntry `json:"inbounds"`
|
||||
Users []userEntry `json:"users"`
|
||||
Onlines []string `json:"onlines"`
|
||||
}
|
||||
|
||||
type trafficEntry struct {
|
||||
Tag string `json:"tag"`
|
||||
Up int64 `json:"up"`
|
||||
Down int64 `json:"down"`
|
||||
}
|
||||
|
||||
type userEntry struct {
|
||||
Email string `json:"email"`
|
||||
Up int64 `json:"up"`
|
||||
Down int64 `json:"down"`
|
||||
}
|
||||
|
||||
// collectAndReportTraffic queries the local xray stats API for per-poll
|
||||
// deltas and posts them to the master. The XrayAPI instance is kept across
|
||||
// polls so delta baselines survive; xray restarts are handled (counters
|
||||
// resetting to zero are treated as fresh).
|
||||
func (a *Agent) collectAndReportTraffic() error {
|
||||
if a.xrayProc == nil || !a.xrayProc.IsRunning() {
|
||||
return nil
|
||||
}
|
||||
if a.apiPort <= 0 {
|
||||
return nil
|
||||
}
|
||||
if err := a.xrayAPI.Init(a.apiPort); err != nil {
|
||||
return err
|
||||
}
|
||||
defer a.xrayAPI.Close()
|
||||
|
||||
traffics, clientTraffics, err := a.xrayAPI.GetTraffic()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
onlineUsers, _ := a.xrayAPI.GetOnlineUsers()
|
||||
|
||||
report := trafficReport{}
|
||||
for _, t := range traffics {
|
||||
if t == nil || !t.IsInbound || t.Up+t.Down <= 0 {
|
||||
continue
|
||||
}
|
||||
report.Inbounds = append(report.Inbounds, trafficEntry{Tag: t.Tag, Up: t.Up, Down: t.Down})
|
||||
}
|
||||
for _, ct := range clientTraffics {
|
||||
if ct == nil || ct.Email == "" || ct.Up+ct.Down <= 0 {
|
||||
continue
|
||||
}
|
||||
report.Users = append(report.Users, userEntry{Email: ct.Email, Up: ct.Up, Down: ct.Down})
|
||||
}
|
||||
for _, u := range onlineUsers {
|
||||
report.Onlines = append(report.Onlines, u.Email)
|
||||
}
|
||||
if len(report.Inbounds) == 0 && len(report.Users) == 0 && len(report.Onlines) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/panel/api/agent/traffic", a.config.MasterURL)
|
||||
body, _ := json.Marshal(report)
|
||||
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+a.config.Token)
|
||||
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("traffic report failed with status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadConfig loads agent config from file
|
||||
func LoadConfig(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
+1
-82
@@ -3,7 +3,6 @@ package database
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -26,7 +25,6 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/random"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
|
||||
"github.com/mattn/go-sqlite3"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
@@ -85,6 +83,7 @@ func allModels() []any {
|
||||
&model.ClientGlobalTraffic{},
|
||||
&model.OutboundSubscription{},
|
||||
&model.SubBalancer{},
|
||||
&model.AgentNode{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2280,86 +2279,6 @@ func IsSQLiteDB(file io.ReaderAt) (bool, error) {
|
||||
return bytes.Equal(buf, signature), nil
|
||||
}
|
||||
|
||||
func BackupSQLite(dstPath string) (err error) {
|
||||
if IsPostgres() {
|
||||
return errors.New("sqlite backup is unavailable for PostgreSQL")
|
||||
}
|
||||
if db == nil {
|
||||
return errors.New("database is not initialized")
|
||||
}
|
||||
if _, err := os.Lstat(dstPath); err == nil {
|
||||
return fmt.Errorf("sqlite backup destination already exists: %s", dstPath)
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = os.Remove(dstPath)
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), backupSQLiteTimeout)
|
||||
defer cancel()
|
||||
|
||||
sourceDB, err := db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sourceConn, err := sourceDB.Conn(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sourceConn.Close()
|
||||
|
||||
destinationDB, err := sql.Open("sqlite3", dstPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer destinationDB.Close()
|
||||
destinationConn, err := destinationDB.Conn(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer destinationConn.Close()
|
||||
|
||||
return sourceConn.Raw(func(sourceDriver any) error {
|
||||
source, ok := sourceDriver.(*sqlite3.SQLiteConn)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected SQLite source connection type %T", sourceDriver)
|
||||
}
|
||||
return destinationConn.Raw(func(destinationDriver any) error {
|
||||
destination, ok := destinationDriver.(*sqlite3.SQLiteConn)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected SQLite destination connection type %T", destinationDriver)
|
||||
}
|
||||
backup, err := destination.Backup("main", source, "main")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
finished := false
|
||||
defer func() {
|
||||
if !finished {
|
||||
_ = backup.Finish()
|
||||
}
|
||||
}()
|
||||
for {
|
||||
done, err := backup.Step(backupSQLiteStepPages())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if done {
|
||||
finished = true
|
||||
return backup.Finish()
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(10 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func ValidateSQLiteDB(dbPath string) error {
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
//go:build cgo
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
func BackupSQLite(dstPath string) (err error) {
|
||||
if IsPostgres() {
|
||||
return errors.New("sqlite backup is unavailable for PostgreSQL")
|
||||
}
|
||||
if db == nil {
|
||||
return errors.New("database is not initialized")
|
||||
}
|
||||
if _, err := os.Lstat(dstPath); err == nil {
|
||||
return fmt.Errorf("sqlite backup destination already exists: %s", dstPath)
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = os.Remove(dstPath)
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), backupSQLiteTimeout)
|
||||
defer cancel()
|
||||
|
||||
sourceDB, err := db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sourceConn, err := sourceDB.Conn(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sourceConn.Close()
|
||||
|
||||
destinationDB, err := sql.Open("sqlite3", dstPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer destinationDB.Close()
|
||||
destinationConn, err := destinationDB.Conn(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer destinationConn.Close()
|
||||
|
||||
return sourceConn.Raw(func(sourceDriver any) error {
|
||||
source, ok := sourceDriver.(*sqlite3.SQLiteConn)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected SQLite source connection type %T", sourceDriver)
|
||||
}
|
||||
return destinationConn.Raw(func(destinationDriver any) error {
|
||||
destination, ok := destinationDriver.(*sqlite3.SQLiteConn)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected SQLite destination connection type %T", destinationDriver)
|
||||
}
|
||||
backup, err := destination.Backup("main", source, "main")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
finished := false
|
||||
defer func() {
|
||||
if !finished {
|
||||
_ = backup.Finish()
|
||||
}
|
||||
}()
|
||||
for {
|
||||
done, err := backup.Step(backupSQLiteStepPages())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if done {
|
||||
finished = true
|
||||
return backup.Finish()
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(10 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !cgo
|
||||
|
||||
package database
|
||||
|
||||
import "errors"
|
||||
|
||||
func BackupSQLite(dstPath string) error {
|
||||
return errors.New("sqlite backup requires CGO build")
|
||||
}
|
||||
@@ -58,6 +58,7 @@ func migrationModels() []any {
|
||||
&model.ClientGlobalTraffic{},
|
||||
&model.OutboundSubscription{},
|
||||
&model.SubBalancer{},
|
||||
&model.AgentNode{},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package model
|
||||
|
||||
// AgentNode binds a lightweight agent (token-registered, no web panel) to a
|
||||
// 3x-ui node row and carries the per-agent egress configuration.
|
||||
//
|
||||
// Agents are the only writers of their own LastSeen; the panel owns everything
|
||||
// else. Raw tokens are never stored — only the SHA-256 hex of the token.
|
||||
type AgentNode struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
TokenHash string `json:"-" gorm:"size:64;uniqueIndex"`
|
||||
TokenPrefix string `json:"tokenPrefix" gorm:"size:16;index"`
|
||||
Name string `json:"name"`
|
||||
// NodeID is the 3x-ui node whose inbounds this agent serves. Nil means the
|
||||
// token was issued but not yet bound to a node.
|
||||
NodeID *int `json:"nodeId" gorm:"index"`
|
||||
// Outbounds is the full xray "outbounds" JSON array pushed to this agent,
|
||||
// replacing the template defaults. Empty means "use template defaults".
|
||||
Outbounds string `json:"outbounds" gorm:"type:text"`
|
||||
// RoutingRules is the full xray "routing.rules" JSON array pushed to this
|
||||
// agent, replacing the template defaults. Empty means "use template defaults".
|
||||
RoutingRules string `json:"routingRules" gorm:"type:text"`
|
||||
LastSeen int64 `json:"lastSeen"`
|
||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
|
||||
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
package ldaputil
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Host string
|
||||
Port int
|
||||
UseTLS bool
|
||||
InsecureSkipVerify bool
|
||||
BindDN string
|
||||
Password string
|
||||
BaseDN string
|
||||
UserFilter string
|
||||
UserAttr string
|
||||
FlagField string
|
||||
TruthyVals []string
|
||||
Invert bool
|
||||
}
|
||||
|
||||
func tlsConfig(cfg Config) *tls.Config {
|
||||
return &tls.Config{InsecureSkipVerify: cfg.InsecureSkipVerify}
|
||||
}
|
||||
|
||||
// FetchVlessFlags returns map[email]enabled
|
||||
func FetchVlessFlags(cfg Config) (map[string]bool, error) {
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
||||
|
||||
scheme := "ldap"
|
||||
if cfg.UseTLS {
|
||||
scheme = "ldaps"
|
||||
}
|
||||
|
||||
ldapURL := fmt.Sprintf("%s://%s", scheme, addr)
|
||||
|
||||
var opts []ldap.DialOpt
|
||||
if cfg.UseTLS {
|
||||
opts = append(opts, ldap.DialWithTLSConfig(tlsConfig(cfg)))
|
||||
}
|
||||
|
||||
conn, err := ldap.DialURL(ldapURL, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if cfg.BindDN != "" {
|
||||
if err := conn.Bind(cfg.BindDN, cfg.Password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.UserFilter == "" {
|
||||
cfg.UserFilter = "(objectClass=person)"
|
||||
}
|
||||
if cfg.UserAttr == "" {
|
||||
cfg.UserAttr = "mail"
|
||||
}
|
||||
// if field not set we fallback to legacy vless_enabled
|
||||
if cfg.FlagField == "" {
|
||||
cfg.FlagField = "vless_enabled"
|
||||
}
|
||||
|
||||
req := ldap.NewSearchRequest(
|
||||
cfg.BaseDN,
|
||||
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
|
||||
cfg.UserFilter,
|
||||
[]string{cfg.UserAttr, cfg.FlagField},
|
||||
nil,
|
||||
)
|
||||
|
||||
res, err := conn.Search(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make(map[string]bool, len(res.Entries))
|
||||
for _, e := range res.Entries {
|
||||
user := e.GetAttributeValue(cfg.UserAttr)
|
||||
if user == "" {
|
||||
continue
|
||||
}
|
||||
val := e.GetAttributeValue(cfg.FlagField)
|
||||
enabled := slices.Contains(cfg.TruthyVals, val)
|
||||
if cfg.Invert {
|
||||
enabled = !enabled
|
||||
}
|
||||
result[user] = enabled
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// AuthenticateUser searches user by cfg.UserAttr and attempts to bind with provided password.
|
||||
func AuthenticateUser(cfg Config, username, password string) (bool, error) {
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
||||
|
||||
scheme := "ldap"
|
||||
if cfg.UseTLS {
|
||||
scheme = "ldaps"
|
||||
}
|
||||
|
||||
ldapURL := fmt.Sprintf("%s://%s", scheme, addr)
|
||||
|
||||
var opts []ldap.DialOpt
|
||||
if cfg.UseTLS {
|
||||
opts = append(opts, ldap.DialWithTLSConfig(tlsConfig(cfg)))
|
||||
}
|
||||
|
||||
conn, err := ldap.DialURL(ldapURL, opts...)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Optional initial bind for search
|
||||
if cfg.BindDN != "" {
|
||||
if err := conn.Bind(cfg.BindDN, cfg.Password); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.UserFilter == "" {
|
||||
cfg.UserFilter = "(objectClass=person)"
|
||||
}
|
||||
if cfg.UserAttr == "" {
|
||||
cfg.UserAttr = "uid"
|
||||
}
|
||||
|
||||
// Build filter to find specific user
|
||||
filter := fmt.Sprintf("(&%s(%s=%s))", cfg.UserFilter, cfg.UserAttr, ldap.EscapeFilter(username))
|
||||
req := ldap.NewSearchRequest(
|
||||
cfg.BaseDN,
|
||||
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 1, 0, false,
|
||||
filter,
|
||||
[]string{"dn"},
|
||||
nil,
|
||||
)
|
||||
res, err := conn.Search(req)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if len(res.Entries) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
userDN := res.Entries[0].DN
|
||||
// Try to bind as the user
|
||||
if err := conn.Bind(userDN, password); err != nil {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package ldaputil
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTLSConfig_InsecureSkipVerifyPropagates(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
skip bool
|
||||
want bool
|
||||
}{
|
||||
{"default verifies", false, false},
|
||||
{"skip flows through", true, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := tlsConfig(Config{InsecureSkipVerify: c.skip})
|
||||
if got.InsecureSkipVerify != c.want {
|
||||
t.Fatalf("InsecureSkipVerify = %v, want %v", got.InsecureSkipVerify, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AgentController serves the lightweight agent API.
|
||||
//
|
||||
// Agent endpoints (register/heartbeat/config/traffic) authenticate with the
|
||||
// agent's own Bearer token, which is NOT a panel API token — they are mounted
|
||||
// on a public route group and must never go through the panel auth middleware.
|
||||
// The panel-authed administration endpoints (token issuance, egress
|
||||
// management) live under the authenticated /panel/api group instead.
|
||||
type AgentController struct {
|
||||
xrayService service.XrayService
|
||||
inboundService service.InboundService
|
||||
}
|
||||
|
||||
func NewAgentController(g *gin.RouterGroup) *AgentController {
|
||||
a := &AgentController{}
|
||||
a.initRouter(g)
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *AgentController) initRouter(g *gin.RouterGroup) {
|
||||
g.POST("/register", a.register)
|
||||
g.POST("/heartbeat", a.heartbeat)
|
||||
g.GET("/config", a.getConfig)
|
||||
g.POST("/traffic", a.reportTraffic)
|
||||
}
|
||||
|
||||
// RegisterAdminRoutes mounts the panel-authenticated administration endpoints
|
||||
// on an already-authed route group.
|
||||
func (a *AgentController) RegisterAdminRoutes(g *gin.RouterGroup) {
|
||||
g.POST("/tokens", a.createToken)
|
||||
g.GET("/tokens", a.listTokens)
|
||||
g.DELETE("/tokens/:id", a.deleteToken)
|
||||
g.GET("/egress/:token", a.getEgress)
|
||||
g.PUT("/egress/:token", a.setEgress)
|
||||
}
|
||||
|
||||
func hashAgentToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
var errRequest = errors.New("bad request")
|
||||
|
||||
func bearerToken(c *gin.Context) string {
|
||||
auth := c.GetHeader("Authorization")
|
||||
if len(auth) > 7 && auth[:7] == "Bearer " {
|
||||
return auth[7:]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a *AgentController) findAgent(token string) (*model.AgentNode, bool) {
|
||||
if token == "" {
|
||||
return nil, false
|
||||
}
|
||||
var agent model.AgentNode
|
||||
if err := database.GetDB().Where("token_hash = ?", hashAgentToken(token)).First(&agent).Error; err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return &agent, true
|
||||
}
|
||||
|
||||
func (a *AgentController) touchAgent(id uint, name string) {
|
||||
updates := map[string]any{"last_seen": time.Now().UnixMilli()}
|
||||
if name != "" {
|
||||
updates["name"] = name
|
||||
}
|
||||
db := database.GetDB()
|
||||
_ = db.Model(&model.AgentNode{}).Where("id = ?", id).Updates(updates).Error
|
||||
// If bound to a node, refresh the node's heartbeat so the legacy node
|
||||
// list shows it online (the legacy probe can't reach agent-only nodes).
|
||||
var nodeID *int
|
||||
_ = db.Model(&model.AgentNode{}).Select("node_id").Where("id = ?", id).Scan(&nodeID).Error
|
||||
if nodeID != nil {
|
||||
now := time.Now().UnixMilli()
|
||||
_ = db.Exec("UPDATE nodes SET last_heartbeat = ?, status = 'online' WHERE id = ?", now, *nodeID).Error
|
||||
}
|
||||
}
|
||||
|
||||
// register activates a pre-issued token. Tokens are issued bound to a node via
|
||||
// POST /panel/api/agent/tokens; the agent only claims its token here.
|
||||
func (a *AgentController) register(c *gin.Context) {
|
||||
var req struct {
|
||||
Token string `json:"token"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.Token == "" {
|
||||
jsonMsg(c, "Token required", errRequest)
|
||||
return
|
||||
}
|
||||
agent, ok := a.findAgent(req.Token)
|
||||
if !ok {
|
||||
c.JSON(401, gin.H{"message": "unknown token"})
|
||||
return
|
||||
}
|
||||
a.touchAgent(uint(agent.Id), req.Name)
|
||||
logger.Infof("Agent registered: %s (token %s...)", req.Name, agent.TokenPrefix)
|
||||
jsonObj(c, gin.H{"message": "registered"}, nil)
|
||||
}
|
||||
|
||||
func (a *AgentController) heartbeat(c *gin.Context) {
|
||||
agent, ok := a.findAgent(bearerToken(c))
|
||||
if !ok {
|
||||
c.JSON(401, gin.H{"message": "unauthorized"})
|
||||
return
|
||||
}
|
||||
a.touchAgent(uint(agent.Id), "")
|
||||
jsonObj(c, gin.H{"message": "ok"}, nil)
|
||||
}
|
||||
|
||||
// getConfig returns the full xray config for the agent's bound node.
|
||||
func (a *AgentController) getConfig(c *gin.Context) {
|
||||
agent, ok := a.findAgent(bearerToken(c))
|
||||
if !ok {
|
||||
c.JSON(401, gin.H{"message": "unauthorized"})
|
||||
return
|
||||
}
|
||||
if agent.NodeID == nil {
|
||||
c.JSON(409, gin.H{"message": "agent token is not bound to a node"})
|
||||
return
|
||||
}
|
||||
cfg, err := a.xrayService.GetXrayConfigForAgent(*agent.NodeID, agent.Outbounds, agent.RoutingRules)
|
||||
if err != nil {
|
||||
jsonMsg(c, "Failed to build xray config", err)
|
||||
return
|
||||
}
|
||||
raw, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
jsonMsg(c, "Failed to encode xray config", err)
|
||||
return
|
||||
}
|
||||
sum := sha256.Sum256(raw)
|
||||
c.JSON(200, gin.H{
|
||||
"config_hash": hex.EncodeToString(sum[:]),
|
||||
"xray_config": json.RawMessage(raw),
|
||||
})
|
||||
}
|
||||
|
||||
type agentTrafficReport struct {
|
||||
Inbounds []struct {
|
||||
Tag string `json:"tag"`
|
||||
Up int64 `json:"up"`
|
||||
Down int64 `json:"down"`
|
||||
} `json:"inbounds"`
|
||||
Users []struct {
|
||||
Email string `json:"email"`
|
||||
Up int64 `json:"up"`
|
||||
Down int64 `json:"down"`
|
||||
} `json:"users"`
|
||||
// Onlines lists client emails with at least one live connection.
|
||||
Onlines []string `json:"onlines"`
|
||||
}
|
||||
|
||||
// reportTraffic ingests per-poll traffic deltas from the agent. Inbound deltas
|
||||
// land on the agent's node inbound rows; client deltas reuse the shared
|
||||
// email-keyed write path (plus its lifecycle maintenance, which also drives
|
||||
// config changes the agent picks up on its next poll).
|
||||
func (a *AgentController) reportTraffic(c *gin.Context) {
|
||||
agent, ok := a.findAgent(bearerToken(c))
|
||||
if !ok {
|
||||
c.JSON(401, gin.H{"message": "unauthorized"})
|
||||
return
|
||||
}
|
||||
var req agentTrafficReport
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
jsonMsg(c, "Invalid request", err)
|
||||
return
|
||||
}
|
||||
db := database.GetDB()
|
||||
if agent.NodeID != nil {
|
||||
for _, ib := range req.Inbounds {
|
||||
if ib.Tag == "" || ib.Up+ib.Down <= 0 {
|
||||
continue
|
||||
}
|
||||
_ = db.Model(&model.Inbound{}).
|
||||
Where("tag = ? AND node_id = ?", ib.Tag, *agent.NodeID).
|
||||
Updates(map[string]any{
|
||||
"up": gorm.Expr(database.ClampedAddExpr("up"), ib.Up),
|
||||
"down": gorm.Expr(database.ClampedAddExpr("down"), ib.Down),
|
||||
}).Error
|
||||
}
|
||||
}
|
||||
var clientTraffics []*xray.ClientTraffic
|
||||
for _, u := range req.Users {
|
||||
if u.Email == "" || u.Up+u.Down <= 0 {
|
||||
continue
|
||||
}
|
||||
clientTraffics = append(clientTraffics, &xray.ClientTraffic{
|
||||
Email: u.Email,
|
||||
Up: u.Up,
|
||||
Down: u.Down,
|
||||
})
|
||||
}
|
||||
if len(clientTraffics) > 0 {
|
||||
if _, _, err := a.inboundService.AddTraffic(nil, clientTraffics); err != nil {
|
||||
jsonMsg(c, "Failed to store traffic", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if agent.NodeID != nil {
|
||||
if len(req.Onlines) > 0 {
|
||||
a.inboundService.SetNodeOnlineTree(*agent.NodeID, map[string][]string{
|
||||
"agent": req.Onlines,
|
||||
})
|
||||
} else {
|
||||
a.inboundService.ClearNodeOnlineClients(*agent.NodeID)
|
||||
}
|
||||
}
|
||||
a.touchAgent(uint(agent.Id), "")
|
||||
jsonObj(c, gin.H{"message": "ok"}, nil)
|
||||
}
|
||||
|
||||
// issueAgentToken generates a new agent token bound to nodeID and stores only
|
||||
// its hash. The raw token is returned once and must be shown to the operator.
|
||||
func issueAgentToken(nodeID *int, name string) (string, int, error) {
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
token := hex.EncodeToString(raw)
|
||||
agent := model.AgentNode{
|
||||
TokenHash: hashAgentToken(token),
|
||||
TokenPrefix: token[:12],
|
||||
Name: name,
|
||||
NodeID: nodeID,
|
||||
}
|
||||
if err := database.GetDB().Create(&agent).Error; err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return token, agent.Id, nil
|
||||
}
|
||||
|
||||
// createToken issues a new agent token bound to a node. The raw token is
|
||||
// returned once; only its hash is stored.
|
||||
func (a *AgentController) createToken(c *gin.Context) {
|
||||
var req struct {
|
||||
NodeID *int `json:"node_id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
jsonMsg(c, "Invalid request", err)
|
||||
return
|
||||
}
|
||||
if req.NodeID != nil {
|
||||
var node model.Node
|
||||
if err := database.GetDB().First(&node, *req.NodeID).Error; err != nil {
|
||||
jsonMsg(c, "Node not found", errRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
token, _, err := issueAgentToken(req.NodeID, req.Name)
|
||||
if err != nil {
|
||||
jsonMsg(c, "Failed to generate token", err)
|
||||
return
|
||||
}
|
||||
var agent model.AgentNode
|
||||
_ = database.GetDB().Where("token_hash = ?", hashAgentToken(token)).First(&agent).Error
|
||||
jsonObj(c, gin.H{
|
||||
"id": agent.Id,
|
||||
"token": token,
|
||||
"token_prefix": agent.TokenPrefix,
|
||||
"node_id": agent.NodeID,
|
||||
"name": agent.Name,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func (a *AgentController) listTokens(c *gin.Context) {
|
||||
var agents []model.AgentNode
|
||||
_ = database.GetDB().Order("id DESC").Find(&agents).Error
|
||||
out := make([]gin.H, 0, len(agents))
|
||||
for _, ag := range agents {
|
||||
out = append(out, gin.H{
|
||||
"id": ag.Id,
|
||||
"token_prefix": ag.TokenPrefix,
|
||||
"name": ag.Name,
|
||||
"node_id": ag.NodeID,
|
||||
"last_seen": ag.LastSeen,
|
||||
"has_egress": ag.Outbounds != "" || ag.RoutingRules != "",
|
||||
"created_at": ag.CreatedAt,
|
||||
})
|
||||
}
|
||||
jsonObj(c, out, nil)
|
||||
}
|
||||
|
||||
func (a *AgentController) deleteToken(c *gin.Context) {
|
||||
_ = database.GetDB().Delete(&model.AgentNode{}, "id = ?", c.Param("id")).Error
|
||||
jsonObj(c, gin.H{"message": "deleted"}, nil)
|
||||
}
|
||||
|
||||
// getEgress returns the panel-managed egress (outbounds + routing rules) for
|
||||
// the agent identified by its token.
|
||||
func (a *AgentController) getEgress(c *gin.Context) {
|
||||
agent, ok := a.findAgent(c.Param("token"))
|
||||
if !ok {
|
||||
c.JSON(404, gin.H{"message": "agent not found"})
|
||||
return
|
||||
}
|
||||
jsonObj(c, gin.H{
|
||||
"id": agent.Id,
|
||||
"token_prefix": agent.TokenPrefix,
|
||||
"name": agent.Name,
|
||||
"node_id": agent.NodeID,
|
||||
"outbounds": json.RawMessage(orEmptyArray(agent.Outbounds)),
|
||||
"routing_rules": json.RawMessage(orEmptyArray(agent.RoutingRules)),
|
||||
}, nil)
|
||||
}
|
||||
|
||||
// setEgress replaces the panel-managed egress for the agent. The panel is the
|
||||
// only source of truth: agents never report local egress, they only apply
|
||||
// what the generated config carries.
|
||||
func (a *AgentController) setEgress(c *gin.Context) {
|
||||
agent, ok := a.findAgent(c.Param("token"))
|
||||
if !ok {
|
||||
c.JSON(404, gin.H{"message": "agent not found"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Outbounds json.RawMessage `json:"outbounds"`
|
||||
RoutingRules json.RawMessage `json:"routing_rules"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
jsonMsg(c, "Invalid request", err)
|
||||
return
|
||||
}
|
||||
outbounds, err := canonicalJSONArray(req.Outbounds)
|
||||
if err != nil {
|
||||
jsonMsg(c, "outbounds must be a JSON array", err)
|
||||
return
|
||||
}
|
||||
rules, err := canonicalJSONArray(req.RoutingRules)
|
||||
if err != nil {
|
||||
jsonMsg(c, "routing_rules must be a JSON array", err)
|
||||
return
|
||||
}
|
||||
if err := database.GetDB().Model(&model.AgentNode{}).Where("id = ?", agent.Id).
|
||||
Updates(map[string]any{"outbounds": outbounds, "routing_rules": rules}).Error; err != nil {
|
||||
jsonMsg(c, "Failed to store egress", err)
|
||||
return
|
||||
}
|
||||
jsonObj(c, gin.H{"message": "saved"}, nil)
|
||||
}
|
||||
|
||||
func orEmptyArray(s string) string {
|
||||
if s == "" {
|
||||
return "[]"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// canonicalJSONArray validates that raw is a JSON array and returns it in
|
||||
// canonical form. Empty input yields an empty string (template defaults).
|
||||
func canonicalJSONArray(raw json.RawMessage) (string, error) {
|
||||
if len(raw) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
var arr []any
|
||||
if err := json.Unmarshal(raw, &arr); err != nil {
|
||||
return "", err
|
||||
}
|
||||
out, err := json.Marshal(arr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
@@ -19,6 +19,7 @@ type APIController struct {
|
||||
inboundController *InboundController
|
||||
serverController *ServerController
|
||||
nodeController *NodeController
|
||||
agentController *AgentController
|
||||
hostController *HostController
|
||||
settingController *SettingController
|
||||
xraySettingController *XraySettingController
|
||||
@@ -173,8 +174,6 @@ func (a *APIController) initRouter(g *gin.RouterGroup) {
|
||||
api.Use(middleware.ConfigEnvelopeMiddleware())
|
||||
api.Use(middleware.CSRFMiddleware())
|
||||
|
||||
api.GET("/openapi.json", ServeOpenAPISpec)
|
||||
|
||||
// Inbounds API
|
||||
inbounds := api.Group("/inbounds")
|
||||
a.inboundController = NewInboundController(inbounds)
|
||||
@@ -191,6 +190,21 @@ func (a *APIController) initRouter(g *gin.RouterGroup) {
|
||||
nodes := api.Group("/nodes")
|
||||
a.nodeController = NewNodeController(nodes)
|
||||
|
||||
// Agent API — lightweight agent mode (no web panel on node).
|
||||
// The agent endpoints (register/heartbeat/config/traffic) authenticate with
|
||||
// the agent's own Bearer token, which is NOT a panel API token, so they are
|
||||
// mounted on a public group and must not pass through checkAPIAuth (it would
|
||||
// 404 every agent call: no session, no panel token).
|
||||
agentPub := g.Group("/panel/api/agent")
|
||||
a.agentController = NewAgentController(agentPub)
|
||||
|
||||
// Agent administration — panel-authenticated (session login or panel API
|
||||
// token via the group's checkAPIAuth): token issuance and per-agent egress
|
||||
// management. Agent Bearer tokens cannot pass checkAPIAuth, so agents can
|
||||
// never reach these.
|
||||
agentAdmin := api.Group("/agent")
|
||||
a.agentController.RegisterAdminRoutes(agentAdmin)
|
||||
|
||||
// Hosts API — per-inbound override endpoints for subscription links
|
||||
hosts := api.Group("/hosts")
|
||||
a.hostController = NewHostController(hosts)
|
||||
|
||||
@@ -2,7 +2,6 @@ package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
htmlpkg "html"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
@@ -24,53 +23,6 @@ func SetDistFS(fsys fs.FS) {
|
||||
|
||||
var distPageBuildTime = time.Now()
|
||||
|
||||
// ServeOpenAPISpec returns the generated OpenAPI 3.0 description of the
|
||||
// panel API. Postman / Insomnia / openapi-generator consume this URL
|
||||
// directly; the in-panel Swagger UI page also fetches it. The spec is
|
||||
// produced at frontend build time by scripts/build-openapi.mjs and
|
||||
// embedded into the binary via the dist FS.
|
||||
func ServeOpenAPISpec(c *gin.Context) {
|
||||
body, err := fs.ReadFile(distFS, "dist/openapi.json")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "msg": "openapi.json not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// The embedded spec ships with `servers: [{url: "/"}]`. When the panel runs
|
||||
// under a non-root web base path, Swagger UI "Try it out" and external
|
||||
// generators must target that prefix, so rewrite the single server entry to
|
||||
// the runtime base path before serving.
|
||||
if basePath := c.GetString("base_path"); basePath != "" && basePath != "/" {
|
||||
if rebuilt, err := withServerBasePath(body, basePath); err != nil {
|
||||
logger.Warning("openapi.json: could not inject base path:", err)
|
||||
} else {
|
||||
body = rebuilt
|
||||
}
|
||||
}
|
||||
|
||||
c.Header("Cache-Control", "public, max-age=300")
|
||||
c.Data(http.StatusOK, "application/json; charset=utf-8", body)
|
||||
}
|
||||
|
||||
// withServerBasePath rewrites the spec's `servers` entry so requests target the
|
||||
// panel's configured web base path. Only the top-level `servers` field is
|
||||
// replaced; every other field is preserved verbatim via json.RawMessage.
|
||||
func withServerBasePath(spec []byte, basePath string) ([]byte, error) {
|
||||
var doc map[string]json.RawMessage
|
||||
if err := json.Unmarshal(spec, &doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
servers, err := json.Marshal([]map[string]string{{
|
||||
"url": strings.TrimSuffix(basePath, "/"),
|
||||
"description": "Current panel",
|
||||
}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doc["servers"] = servers
|
||||
return json.Marshal(doc)
|
||||
}
|
||||
|
||||
func normalizeWebBasePath(basePath string) string {
|
||||
if basePath == "" {
|
||||
return "/"
|
||||
|
||||
@@ -17,9 +17,8 @@ import (
|
||||
|
||||
// LoginForm represents the login request structure.
|
||||
type LoginForm struct {
|
||||
Username string `json:"username" form:"username"`
|
||||
Password string `json:"password" form:"password"`
|
||||
TwoFactorCode string `json:"twoFactorCode" form:"twoFactorCode"`
|
||||
Username string `json:"username" form:"username"`
|
||||
Password string `json:"password" form:"password"`
|
||||
}
|
||||
|
||||
// IndexController handles the main index and login-related routes.
|
||||
@@ -38,14 +37,13 @@ func NewIndexController(g *gin.RouterGroup) *IndexController {
|
||||
return a
|
||||
}
|
||||
|
||||
// initRouter sets up the routes for index, login, logout, and two-factor authentication.
|
||||
// initRouter sets up the routes for index, login, and logout.
|
||||
func (a *IndexController) initRouter(g *gin.RouterGroup) {
|
||||
g.GET("/", a.index)
|
||||
g.GET("/csrf-token", a.csrfToken)
|
||||
|
||||
g.POST("/login", middleware.CSRFMiddleware(), a.login)
|
||||
g.POST("/logout", middleware.CSRFMiddleware(), a.logout)
|
||||
g.POST("/getTwoFactorEnable", middleware.CSRFMiddleware(), a.getTwoFactorEnable)
|
||||
}
|
||||
|
||||
// index handles the root route, redirecting logged-in users to the panel or showing the login page.
|
||||
@@ -92,7 +90,7 @@ func (a *IndexController) login(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
user, checkErr := a.userService.CheckUser(form.Username, form.Password, form.TwoFactorCode)
|
||||
user, checkErr := a.userService.CheckUser(form.Username, form.Password)
|
||||
|
||||
if user == nil {
|
||||
reason := loginFailureReason(checkErr)
|
||||
@@ -159,9 +157,3 @@ func (a *IndexController) csrfToken(c *gin.Context) {
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "obj": token})
|
||||
}
|
||||
|
||||
// getTwoFactorEnable retrieves the current status of two-factor authentication.
|
||||
func (a *IndexController) getTwoFactorEnable(c *gin.Context) {
|
||||
status, err := a.settingService.GetTwoFactorEnable()
|
||||
jsonObj(c, status, err)
|
||||
}
|
||||
|
||||
@@ -144,7 +144,9 @@ func (a *NodeController) add(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if n.OutboundTag == "" {
|
||||
isAgent := n.NodeType == "agent"
|
||||
// Agent nodes have no panel to probe; they authenticate via agent token.
|
||||
if n.OutboundTag == "" && !isAgent {
|
||||
if err := a.ensureReachable(c, n, 0); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
|
||||
return
|
||||
@@ -164,6 +166,22 @@ func (a *NodeController) add(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
// For agent nodes, issue an agent token bound to the new node and
|
||||
// return it so the UI can display it (shown only once).
|
||||
if isAgent {
|
||||
nodeID := view.Id
|
||||
token, _, err := issueAgentToken(&nodeID, n.Name)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
|
||||
return
|
||||
}
|
||||
jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.add"), gin.H{
|
||||
"node": view,
|
||||
"agentToken": token,
|
||||
"nodeType": "agent",
|
||||
}, nil)
|
||||
return
|
||||
}
|
||||
jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.add"), view, nil)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
@@ -12,7 +11,6 @@ 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"
|
||||
|
||||
@@ -21,23 +19,19 @@ import (
|
||||
|
||||
// updateUserForm represents the form for updating user credentials.
|
||||
type updateUserForm struct {
|
||||
OldUsername string `json:"oldUsername" form:"oldUsername"`
|
||||
OldPassword string `json:"oldPassword" form:"oldPassword"`
|
||||
NewUsername string `json:"newUsername" form:"newUsername"`
|
||||
NewPassword string `json:"newPassword" form:"newPassword"`
|
||||
TwoFactorCode string `json:"twoFactorCode" form:"twoFactorCode"`
|
||||
OldUsername string `json:"oldUsername" form:"oldUsername"`
|
||||
OldPassword string `json:"oldPassword" form:"oldPassword"`
|
||||
NewUsername string `json:"newUsername" form:"newUsername"`
|
||||
NewPassword string `json:"newPassword" form:"newPassword"`
|
||||
}
|
||||
|
||||
// updateSettingForm carries the persisted settings plus request-scoped fields
|
||||
// that must never land in the settings table: the 2FA confirmation code and
|
||||
// the explicit clear flags for redacted secrets (a blank secret alone means
|
||||
// "unchanged", so clearing needs its own signal — see #5724).
|
||||
// updateSettingForm carries the persisted settings plus the explicit clear
|
||||
// flags for redacted secrets, which must never land in the settings table (a
|
||||
// blank secret alone means "unchanged", so clearing needs its own signal —
|
||||
// see #5724).
|
||||
type updateSettingForm struct {
|
||||
entity.AllSetting
|
||||
TwoFactorCode string `json:"twoFactorCode" form:"twoFactorCode"`
|
||||
ClearTgBotToken bool `json:"clearTgBotToken" form:"clearTgBotToken"`
|
||||
ClearLdapPassword bool `json:"clearLdapPassword" form:"clearLdapPassword"`
|
||||
ClearSmtpPassword bool `json:"clearSmtpPassword" form:"clearSmtpPassword"`
|
||||
ClearTgBotToken bool `json:"clearTgBotToken" form:"clearTgBotToken"`
|
||||
}
|
||||
|
||||
type validateRegexForm struct {
|
||||
@@ -76,7 +70,6 @@ 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)
|
||||
}
|
||||
|
||||
@@ -125,34 +118,14 @@ func (a *SettingController) updateSetting(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
allSetting := &form.AllSetting
|
||||
oldTwoFactor, twoFactorErr := a.settingService.GetTwoFactorEnable()
|
||||
oldPanelOutbound, _ := a.settingService.GetPanelOutbound()
|
||||
oldTgEnable, _ := a.settingService.GetTgbotEnabled()
|
||||
oldTgToken, _ := a.settingService.GetTgBotToken()
|
||||
oldTgChatId, _ := a.settingService.GetTgBotChatId()
|
||||
oldTgAPIServer, _ := a.settingService.GetTgBotAPIServer()
|
||||
if twoFactorErr == nil && oldTwoFactor {
|
||||
// Rebinding the authenticator is the same class of change as turning 2FA
|
||||
// off, so both need a current code. Blank still means "unchanged".
|
||||
submittedToken := strings.TrimSpace(allSetting.TwoFactorToken)
|
||||
storedToken, _ := a.settingService.GetTwoFactorToken()
|
||||
if !allSetting.TwoFactorEnable || (submittedToken != "" && submittedToken != storedToken) {
|
||||
if err := a.settingService.VerifyTwoFactorCode(form.TwoFactorCode); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
err := a.settingService.UpdateAllSetting(allSetting, service.SecretClears{
|
||||
TgBotToken: form.ClearTgBotToken,
|
||||
LdapPassword: form.ClearLdapPassword,
|
||||
SmtpPassword: form.ClearSmtpPassword,
|
||||
TgBotToken: form.ClearTgBotToken,
|
||||
})
|
||||
if err == nil && twoFactorErr == nil && !oldTwoFactor && allSetting.TwoFactorEnable {
|
||||
if bumpErr := a.userService.BumpLoginEpoch(); bumpErr != nil {
|
||||
err = bumpErr
|
||||
}
|
||||
}
|
||||
if err == nil && form.PanelOutbound != oldPanelOutbound {
|
||||
// The egress bridge lives in the generated config; reconcile the
|
||||
// running core. One SOCKS inbound plus one routing rule — both
|
||||
@@ -191,10 +164,6 @@ func (a *SettingController) updateUser(c *gin.Context) {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifyUserError"), errors.New(I18nWeb(c, "pages.settings.toasts.userPassMustBeNotEmpty")))
|
||||
return
|
||||
}
|
||||
if err := a.settingService.VerifyTwoFactorCode(form.TwoFactorCode); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifyUserError"), err)
|
||||
return
|
||||
}
|
||||
err = a.userService.UpdateUser(user.Id, form.NewUsername, form.NewPassword)
|
||||
if err == nil {
|
||||
user.Username = form.NewUsername
|
||||
@@ -288,30 +257,6 @@ func (a *SettingController) setApiTokenEnabled(c *gin.Context) {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), a.apiTokenService.SetEnabledExpectedScope(id, form.ExpectedScope, 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 {
|
||||
@@ -341,9 +286,3 @@ func SetTestTgFunc(fn func() error) { testTgFunc = fn }
|
||||
var reloadTgbotFunc func()
|
||||
|
||||
func SetReloadTgbotFunc(fn func()) { reloadTgbotFunc = 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 }
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"crypto/tls"
|
||||
"math"
|
||||
"net"
|
||||
"net/mail"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -49,87 +48,48 @@ type AllSetting struct {
|
||||
TgLang string `json:"tgLang" form:"tgLang"`
|
||||
TgEnabledEvents string `json:"tgEnabledEvents" form:"tgEnabledEvents"`
|
||||
|
||||
SmtpEnable bool `json:"smtpEnable" form:"smtpEnable"`
|
||||
SmtpHost string `json:"smtpHost" form:"smtpHost"`
|
||||
SmtpPort int `json:"smtpPort" form:"smtpPort" validate:"gte=1,lte=65535"`
|
||||
SmtpUsername string `json:"smtpUsername" form:"smtpUsername"`
|
||||
SmtpPassword string `json:"smtpPassword" form:"smtpPassword"`
|
||||
SmtpFrom string `json:"smtpFrom" form:"smtpFrom"`
|
||||
SmtpFromName string `json:"smtpFromName" form:"smtpFromName"`
|
||||
SmtpTo string `json:"smtpTo" form:"smtpTo"`
|
||||
SmtpEncryptionType string `json:"smtpEncryptionType" form:"smtpEncryptionType"`
|
||||
SmtpEnabledEvents string `json:"smtpEnabledEvents" form:"smtpEnabledEvents"`
|
||||
SmtpCpu int `json:"smtpCpu" form:"smtpCpu" validate:"gte=0,lte=100"`
|
||||
SmtpMemory int `json:"smtpMemory" form:"smtpMemory" validate:"gte=0,lte=100"`
|
||||
|
||||
OutboundDownThreshold int `json:"outboundDownThreshold" form:"outboundDownThreshold" validate:"gte=1,lte=100"`
|
||||
|
||||
TimeLocation string `json:"timeLocation" form:"timeLocation"`
|
||||
TwoFactorEnable bool `json:"twoFactorEnable" form:"twoFactorEnable"`
|
||||
TwoFactorToken string `json:"twoFactorToken" form:"twoFactorToken"`
|
||||
TimeLocation string `json:"timeLocation" form:"timeLocation"`
|
||||
|
||||
SubEnable bool `json:"subEnable" form:"subEnable"`
|
||||
SubJsonEnable bool `json:"subJsonEnable" form:"subJsonEnable"`
|
||||
SubJsonAutoDetect bool `json:"subJsonAutoDetect" form:"subJsonAutoDetect"`
|
||||
SubJsonAlwaysArray bool `json:"subJsonAlwaysArray" form:"subJsonAlwaysArray"`
|
||||
SubJsonUserAgentRegex string `json:"subJsonUserAgentRegex" form:"subJsonUserAgentRegex"`
|
||||
SubClashAutoDetect bool `json:"subClashAutoDetect" form:"subClashAutoDetect"`
|
||||
SubClashUserAgentRegex string `json:"subClashUserAgentRegex" form:"subClashUserAgentRegex"`
|
||||
SubTitle string `json:"subTitle" form:"subTitle"`
|
||||
SubSupportUrl string `json:"subSupportUrl" form:"subSupportUrl"`
|
||||
SubProfileUrl string `json:"subProfileUrl" form:"subProfileUrl"`
|
||||
SubAnnounce string `json:"subAnnounce" form:"subAnnounce"`
|
||||
SubEnableRouting bool `json:"subEnableRouting" form:"subEnableRouting"`
|
||||
SubRoutingRules string `json:"subRoutingRules" form:"subRoutingRules"`
|
||||
SubIncyEnableRouting bool `json:"subIncyEnableRouting" form:"subIncyEnableRouting"`
|
||||
SubIncyRoutingRules string `json:"subIncyRoutingRules" form:"subIncyRoutingRules"`
|
||||
SubListen string `json:"subListen" form:"subListen"`
|
||||
SubPort int `json:"subPort" form:"subPort" validate:"gte=1,lte=65535"`
|
||||
SubPath string `json:"subPath" form:"subPath"`
|
||||
SubDomain string `json:"subDomain" form:"subDomain"`
|
||||
SubCertFile string `json:"subCertFile" form:"subCertFile"`
|
||||
SubKeyFile string `json:"subKeyFile" form:"subKeyFile"`
|
||||
SubUpdates int `json:"subUpdates" form:"subUpdates" validate:"gte=0,lte=525600"`
|
||||
ExternalTrafficInformEnable bool `json:"externalTrafficInformEnable" form:"externalTrafficInformEnable"`
|
||||
ExternalTrafficInformURI string `json:"externalTrafficInformURI" form:"externalTrafficInformURI"`
|
||||
RestartXrayOnClientDisable bool `json:"restartXrayOnClientDisable" form:"restartXrayOnClientDisable"`
|
||||
SubEncrypt bool `json:"subEncrypt" form:"subEncrypt"`
|
||||
SubURI string `json:"subURI" form:"subURI"`
|
||||
SubJsonPath string `json:"subJsonPath" form:"subJsonPath"`
|
||||
SubJsonURI string `json:"subJsonURI" form:"subJsonURI"`
|
||||
SubClashEnable bool `json:"subClashEnable" form:"subClashEnable"`
|
||||
SubClashPath string `json:"subClashPath" form:"subClashPath"`
|
||||
SubClashURI string `json:"subClashURI" form:"subClashURI"`
|
||||
SubClashEnableRouting bool `json:"subClashEnableRouting" form:"subClashEnableRouting"`
|
||||
SubClashRules string `json:"subClashRules" form:"subClashRules"`
|
||||
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"`
|
||||
SubJsonRules string `json:"subJsonRules" form:"subJsonRules"`
|
||||
SubJsonFinalMask string `json:"subJsonFinalMask" form:"subJsonFinalMask"`
|
||||
SubJsonObservatory string `json:"subJsonObservatory" form:"subJsonObservatory"`
|
||||
SubThemeDir string `json:"subThemeDir" form:"subThemeDir"`
|
||||
SubHideSettings bool `json:"subHideSettings" form:"subHideSettings"`
|
||||
|
||||
LdapEnable bool `json:"ldapEnable" form:"ldapEnable"`
|
||||
LdapHost string `json:"ldapHost" form:"ldapHost"`
|
||||
LdapPort int `json:"ldapPort" form:"ldapPort" validate:"gte=0,lte=65535"`
|
||||
LdapUseTLS bool `json:"ldapUseTLS" form:"ldapUseTLS"`
|
||||
LdapInsecureSkipVerify bool `json:"ldapInsecureSkipVerify" form:"ldapInsecureSkipVerify"`
|
||||
LdapBindDN string `json:"ldapBindDN" form:"ldapBindDN"`
|
||||
LdapPassword string `json:"ldapPassword" form:"ldapPassword"`
|
||||
LdapBaseDN string `json:"ldapBaseDN" form:"ldapBaseDN"`
|
||||
LdapUserFilter string `json:"ldapUserFilter" form:"ldapUserFilter"`
|
||||
LdapUserAttr string `json:"ldapUserAttr" form:"ldapUserAttr"`
|
||||
LdapVlessField string `json:"ldapVlessField" form:"ldapVlessField"`
|
||||
LdapSyncCron string `json:"ldapSyncCron" form:"ldapSyncCron"`
|
||||
LdapFlagField string `json:"ldapFlagField" form:"ldapFlagField"`
|
||||
LdapTruthyValues string `json:"ldapTruthyValues" form:"ldapTruthyValues"`
|
||||
LdapInvertFlag bool `json:"ldapInvertFlag" form:"ldapInvertFlag"`
|
||||
LdapInboundTags string `json:"ldapInboundTags" form:"ldapInboundTags"`
|
||||
LdapAutoCreate bool `json:"ldapAutoCreate" form:"ldapAutoCreate"`
|
||||
LdapAutoDelete bool `json:"ldapAutoDelete" form:"ldapAutoDelete"`
|
||||
LdapDefaultTotalGB int `json:"ldapDefaultTotalGB" form:"ldapDefaultTotalGB" validate:"gte=0"`
|
||||
LdapDefaultExpiryDays int `json:"ldapDefaultExpiryDays" form:"ldapDefaultExpiryDays" validate:"gte=0"`
|
||||
LdapDefaultLimitIP int `json:"ldapDefaultLimitIP" form:"ldapDefaultLimitIP" validate:"gte=0"`
|
||||
SubEnable bool `json:"subEnable" form:"subEnable"`
|
||||
SubJsonEnable bool `json:"subJsonEnable" form:"subJsonEnable"`
|
||||
SubJsonAutoDetect bool `json:"subJsonAutoDetect" form:"subJsonAutoDetect"`
|
||||
SubJsonAlwaysArray bool `json:"subJsonAlwaysArray" form:"subJsonAlwaysArray"`
|
||||
SubJsonUserAgentRegex string `json:"subJsonUserAgentRegex" form:"subJsonUserAgentRegex"`
|
||||
SubClashAutoDetect bool `json:"subClashAutoDetect" form:"subClashAutoDetect"`
|
||||
SubClashUserAgentRegex string `json:"subClashUserAgentRegex" form:"subClashUserAgentRegex"`
|
||||
SubTitle string `json:"subTitle" form:"subTitle"`
|
||||
SubSupportUrl string `json:"subSupportUrl" form:"subSupportUrl"`
|
||||
SubProfileUrl string `json:"subProfileUrl" form:"subProfileUrl"`
|
||||
SubAnnounce string `json:"subAnnounce" form:"subAnnounce"`
|
||||
SubEnableRouting bool `json:"subEnableRouting" form:"subEnableRouting"`
|
||||
SubRoutingRules string `json:"subRoutingRules" form:"subRoutingRules"`
|
||||
SubIncyEnableRouting bool `json:"subIncyEnableRouting" form:"subIncyEnableRouting"`
|
||||
SubIncyRoutingRules string `json:"subIncyRoutingRules" form:"subIncyRoutingRules"`
|
||||
SubListen string `json:"subListen" form:"subListen"`
|
||||
SubPort int `json:"subPort" form:"subPort" validate:"gte=1,lte=65535"`
|
||||
SubPath string `json:"subPath" form:"subPath"`
|
||||
SubDomain string `json:"subDomain" form:"subDomain"`
|
||||
SubCertFile string `json:"subCertFile" form:"subCertFile"`
|
||||
SubKeyFile string `json:"subKeyFile" form:"subKeyFile"`
|
||||
SubUpdates int `json:"subUpdates" form:"subUpdates" validate:"gte=0,lte=525600"`
|
||||
RestartXrayOnClientDisable bool `json:"restartXrayOnClientDisable" form:"restartXrayOnClientDisable"`
|
||||
SubEncrypt bool `json:"subEncrypt" form:"subEncrypt"`
|
||||
SubURI string `json:"subURI" form:"subURI"`
|
||||
SubJsonPath string `json:"subJsonPath" form:"subJsonPath"`
|
||||
SubJsonURI string `json:"subJsonURI" form:"subJsonURI"`
|
||||
SubClashEnable bool `json:"subClashEnable" form:"subClashEnable"`
|
||||
SubClashPath string `json:"subClashPath" form:"subClashPath"`
|
||||
SubClashURI string `json:"subClashURI" form:"subClashURI"`
|
||||
SubClashEnableRouting bool `json:"subClashEnableRouting" form:"subClashEnableRouting"`
|
||||
SubClashRules string `json:"subClashRules" form:"subClashRules"`
|
||||
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"`
|
||||
SubJsonRules string `json:"subJsonRules" form:"subJsonRules"`
|
||||
SubJsonFinalMask string `json:"subJsonFinalMask" form:"subJsonFinalMask"`
|
||||
SubJsonObservatory string `json:"subJsonObservatory" form:"subJsonObservatory"`
|
||||
SubThemeDir string `json:"subThemeDir" form:"subThemeDir"`
|
||||
SubHideSettings bool `json:"subHideSettings" form:"subHideSettings"`
|
||||
|
||||
WarpUpdateInterval int `json:"warpUpdateInterval" form:"warpUpdateInterval" validate:"gte=0"`
|
||||
}
|
||||
@@ -137,13 +97,10 @@ type AllSetting struct {
|
||||
type AllSettingView struct {
|
||||
AllSetting
|
||||
|
||||
HasTgBotToken bool `json:"hasTgBotToken"`
|
||||
HasTwoFactorToken bool `json:"hasTwoFactorToken"`
|
||||
HasLdapPassword bool `json:"hasLdapPassword"`
|
||||
HasApiToken bool `json:"hasApiToken"`
|
||||
HasWarpSecret bool `json:"hasWarpSecret"`
|
||||
HasNordSecret bool `json:"hasNordSecret"`
|
||||
HasSmtpPassword bool `json:"hasSmtpPassword"`
|
||||
HasTgBotToken bool `json:"hasTgBotToken"`
|
||||
HasApiToken bool `json:"hasApiToken"`
|
||||
HasWarpSecret bool `json:"hasWarpSecret"`
|
||||
HasNordSecret bool `json:"hasNordSecret"`
|
||||
}
|
||||
|
||||
func pathHasForbiddenChar(s string) bool {
|
||||
@@ -288,12 +245,6 @@ func (s *AllSetting) CheckValid() error {
|
||||
return common.NewError("time location not exist:", s.TimeLocation)
|
||||
}
|
||||
|
||||
if s.SmtpFrom != "" {
|
||||
if _, err := mail.ParseAddress(s.SmtpFrom); err != nil {
|
||||
return common.NewError("SMTP from address is not valid:", s.SmtpFrom)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
package job
|
||||
|
||||
import "testing"
|
||||
|
||||
// A bind that succeeds with an unusable search returns (empty, nil); the only
|
||||
// guard was `err != nil`, so that answer detached the entire inbound.
|
||||
func TestAutoDeleteAllowed(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
previous int64
|
||||
fetched int
|
||||
want bool
|
||||
}{
|
||||
{"empty fetch on a fresh job is refused", 0, 0, false},
|
||||
{"empty fetch after a healthy sync is refused", 500, 0, false},
|
||||
{"full fetch on a fresh job is allowed", 0, 500, true},
|
||||
{"steady fetch is allowed", 500, 500, true},
|
||||
{"growth is allowed", 500, 900, true},
|
||||
{"shrink above the retention floor is allowed", 500, 250, true},
|
||||
{"shrink below the retention floor is refused", 500, 249, false},
|
||||
{"collapse to a single user is refused", 500, 1, false},
|
||||
{"single user with no history is allowed", 0, 1, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
j := NewLdapSyncJob()
|
||||
j.lastFlagCount.Store(c.previous)
|
||||
if got := j.autoDeleteSafeForFetch(c.fetched); got != c.want {
|
||||
t.Fatalf("autoDeleteSafeForFetch(previous=%d, fetched=%d) = %v, want %v",
|
||||
c.previous, c.fetched, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,342 +0,0 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
ldaputil "github.com/mhsanaei/3x-ui/v3/internal/util/ldap"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
var DefaultTruthyValues = []string{"true", "1", "yes", "on"}
|
||||
|
||||
// Share of the previous successful fetch a new one must still return to be
|
||||
// trusted: a sudden collapse means a broken directory far more often than churn.
|
||||
const ldapAutoDeleteMinRetainPercent = 50
|
||||
|
||||
type LdapSyncJob struct {
|
||||
settingService service.SettingService
|
||||
inboundService service.InboundService
|
||||
clientService service.ClientService
|
||||
xrayService service.XrayService
|
||||
lastFlagCount atomic.Int64
|
||||
}
|
||||
|
||||
// --- Helper functions for mustGet ---
|
||||
func mustGetString(fn func() (string, error)) string {
|
||||
v, err := fn()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func mustGetInt(fn func() (int, error)) int {
|
||||
v, err := fn()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func mustGetBool(fn func() (bool, error)) bool {
|
||||
v, err := fn()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func mustGetStringOr(fn func() (string, error), fallback string) string {
|
||||
v, err := fn()
|
||||
if err != nil || v == "" {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func NewLdapSyncJob() *LdapSyncJob {
|
||||
return new(LdapSyncJob)
|
||||
}
|
||||
|
||||
func (j *LdapSyncJob) Run() {
|
||||
logger.Info("LDAP sync job started")
|
||||
|
||||
enabled, err := j.settingService.GetLdapEnable()
|
||||
if err != nil || !enabled {
|
||||
logger.Warning("LDAP disabled or failed to fetch flag")
|
||||
return
|
||||
}
|
||||
|
||||
// --- LDAP fetch ---
|
||||
cfg := ldaputil.Config{
|
||||
Host: mustGetString(j.settingService.GetLdapHost),
|
||||
Port: mustGetInt(j.settingService.GetLdapPort),
|
||||
UseTLS: mustGetBool(j.settingService.GetLdapUseTLS),
|
||||
InsecureSkipVerify: mustGetBool(j.settingService.GetLdapInsecureSkipVerify),
|
||||
BindDN: mustGetString(j.settingService.GetLdapBindDN),
|
||||
Password: mustGetString(j.settingService.GetLdapPassword),
|
||||
BaseDN: mustGetString(j.settingService.GetLdapBaseDN),
|
||||
UserFilter: mustGetString(j.settingService.GetLdapUserFilter),
|
||||
UserAttr: mustGetString(j.settingService.GetLdapUserAttr),
|
||||
FlagField: mustGetStringOr(j.settingService.GetLdapFlagField, mustGetString(j.settingService.GetLdapVlessField)),
|
||||
TruthyVals: truthyValuesOrDefault(mustGetString(j.settingService.GetLdapTruthyValues)),
|
||||
Invert: mustGetBool(j.settingService.GetLdapInvertFlag),
|
||||
}
|
||||
|
||||
flags, err := ldaputil.FetchVlessFlags(cfg)
|
||||
if err != nil {
|
||||
logger.Warning("LDAP fetch failed:", err)
|
||||
return
|
||||
}
|
||||
logger.Infof("Fetched %d LDAP flags", len(flags))
|
||||
|
||||
// --- Load all inbounds and all clients once ---
|
||||
inboundTags := splitCsv(mustGetString(j.settingService.GetLdapInboundTags))
|
||||
inbounds, err := j.inboundService.GetAllInbounds()
|
||||
if err != nil {
|
||||
logger.Warning("Failed to get inbounds:", err)
|
||||
return
|
||||
}
|
||||
|
||||
allClients := map[string]*model.Client{} // email -> client
|
||||
inboundMap := map[string]*model.Inbound{} // tag -> inbound
|
||||
for _, ib := range inbounds {
|
||||
inboundMap[ib.Tag] = ib
|
||||
clients, _ := j.inboundService.GetClients(ib)
|
||||
for i := range clients {
|
||||
allClients[clients[i].Email] = &clients[i]
|
||||
}
|
||||
}
|
||||
|
||||
// --- Prepare batch operations ---
|
||||
autoCreate := mustGetBool(j.settingService.GetLdapAutoCreate)
|
||||
defGB := mustGetInt(j.settingService.GetLdapDefaultTotalGB)
|
||||
defExpiryDays := mustGetInt(j.settingService.GetLdapDefaultExpiryDays)
|
||||
defLimitIP := mustGetInt(j.settingService.GetLdapDefaultLimitIP)
|
||||
|
||||
resolvedInboundIds := make([]int, 0, len(inboundTags))
|
||||
resolvedTags := make([]string, 0, len(inboundTags))
|
||||
for _, tag := range inboundTags {
|
||||
ib := inboundMap[tag]
|
||||
if ib == nil {
|
||||
logger.Warningf("LDAP inbound tag %s does not match any inbound", tag)
|
||||
continue
|
||||
}
|
||||
resolvedInboundIds = append(resolvedInboundIds, ib.Id)
|
||||
resolvedTags = append(resolvedTags, tag)
|
||||
}
|
||||
|
||||
clientsToCreate := []model.Client{}
|
||||
clientsToEnable := map[string][]string{} // tag -> []email
|
||||
clientsToDisable := map[string][]string{} // tag -> []email
|
||||
|
||||
for email, allowed := range flags {
|
||||
existing := allClients[email]
|
||||
if existing == nil {
|
||||
if allowed && autoCreate {
|
||||
clientsToCreate = append(clientsToCreate, j.buildClient(email, defGB, defExpiryDays, defLimitIP))
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, tag := range resolvedTags {
|
||||
if allowed && !existing.Enable {
|
||||
clientsToEnable[tag] = append(clientsToEnable[tag], email)
|
||||
} else if !allowed && existing.Enable {
|
||||
clientsToDisable[tag] = append(clientsToDisable[tag], email)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
j.createClients(clientsToCreate, resolvedInboundIds, resolvedTags)
|
||||
|
||||
// --- Execute enable/disable batch ---
|
||||
for tag, emails := range clientsToEnable {
|
||||
j.batchSetEnable(inboundMap[tag], emails, true)
|
||||
}
|
||||
for tag, emails := range clientsToDisable {
|
||||
j.batchSetEnable(inboundMap[tag], emails, false)
|
||||
}
|
||||
|
||||
// --- Auto delete clients not in LDAP ---
|
||||
autoDelete := mustGetBool(j.settingService.GetLdapAutoDelete)
|
||||
if autoDelete && j.autoDeleteSafeForFetch(len(flags)) {
|
||||
ldapEmailSet := map[string]struct{}{}
|
||||
for e := range flags {
|
||||
ldapEmailSet[e] = struct{}{}
|
||||
}
|
||||
for _, tag := range inboundTags {
|
||||
j.deleteClientsNotInLDAP(tag, ldapEmailSet)
|
||||
}
|
||||
}
|
||||
j.lastFlagCount.Store(int64(len(flags)))
|
||||
}
|
||||
|
||||
// FetchVlessFlags returns (empty, nil) when the bind succeeds but the search
|
||||
// yields nothing — a renamed OU, a lost read grant — which is not "all gone".
|
||||
func (j *LdapSyncJob) autoDeleteSafeForFetch(fetched int) bool {
|
||||
if fetched == 0 {
|
||||
logger.Warning("LDAP auto-delete skipped: directory returned no usable users")
|
||||
return false
|
||||
}
|
||||
previous := j.lastFlagCount.Load()
|
||||
if previous > 0 && int64(fetched)*100 < previous*ldapAutoDeleteMinRetainPercent {
|
||||
logger.Warningf("LDAP auto-delete skipped: fetched %d users, previous successful sync saw %d (below %d%% retention)",
|
||||
fetched, previous, ldapAutoDeleteMinRetainPercent)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func truthyValuesOrDefault(s string) []string {
|
||||
if vals := splitCsv(s); len(vals) > 0 {
|
||||
return vals
|
||||
}
|
||||
return DefaultTruthyValues
|
||||
}
|
||||
|
||||
func splitCsv(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
v := strings.TrimSpace(p)
|
||||
if v != "" {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildClient creates a new client for auto-create; ClientService.Create fills per-protocol credentials
|
||||
func (j *LdapSyncJob) buildClient(email string, defGB, defExpiryDays, defLimitIP int) model.Client {
|
||||
c := model.Client{
|
||||
Email: email,
|
||||
Enable: true,
|
||||
LimitIP: defLimitIP,
|
||||
TotalGB: int64(defGB) * 1024 * 1024 * 1024,
|
||||
}
|
||||
if defExpiryDays > 0 {
|
||||
c.ExpiryTime = time.Now().Add(time.Duration(defExpiryDays) * 24 * time.Hour).UnixMilli()
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// createClients adds each new LDAP client once, attached to every configured inbound
|
||||
func (j *LdapSyncJob) createClients(newClients []model.Client, inboundIds []int, tags []string) {
|
||||
if len(newClients) == 0 || len(inboundIds) == 0 {
|
||||
return
|
||||
}
|
||||
tagList := strings.Join(tags, ",")
|
||||
created := 0
|
||||
restartNeeded := false
|
||||
for _, c := range newClients {
|
||||
nr, err := j.clientService.Create(&j.inboundService, &service.ClientCreatePayload{Client: c, InboundIds: inboundIds})
|
||||
if err != nil {
|
||||
logger.Warningf("Failed to add client %s for tags %s: %v", c.Email, tagList, err)
|
||||
continue
|
||||
}
|
||||
created++
|
||||
if nr {
|
||||
restartNeeded = true
|
||||
}
|
||||
}
|
||||
if created == 0 {
|
||||
return
|
||||
}
|
||||
logger.Infof("LDAP auto-create: %d clients for %s", created, tagList)
|
||||
if restartNeeded {
|
||||
j.xrayService.SetToNeedRestart()
|
||||
}
|
||||
}
|
||||
|
||||
func (j *LdapSyncJob) batchSetEnable(ib *model.Inbound, emails []string, enable bool) {
|
||||
if len(emails) == 0 {
|
||||
return
|
||||
}
|
||||
restartNeeded := false
|
||||
changed := 0
|
||||
for _, email := range emails {
|
||||
ok, needRestart, err := j.clientService.SetClientEnableByEmail(&j.inboundService, email, enable)
|
||||
if err != nil {
|
||||
logger.Warningf("Batch set enable failed for %s in inbound %s: %v", email, ib.Tag, err)
|
||||
continue
|
||||
}
|
||||
if ok {
|
||||
changed++
|
||||
}
|
||||
if needRestart {
|
||||
restartNeeded = true
|
||||
}
|
||||
}
|
||||
if changed > 0 {
|
||||
logger.Infof("Batch set enable=%v for %d clients in inbound %s", enable, changed, ib.Tag)
|
||||
}
|
||||
if restartNeeded {
|
||||
j.xrayService.SetToNeedRestart()
|
||||
}
|
||||
}
|
||||
|
||||
// deleteClientsNotInLDAP deletes clients not in LDAP using batches and a single restart
|
||||
func (j *LdapSyncJob) deleteClientsNotInLDAP(inboundTag string, ldapEmails map[string]struct{}) {
|
||||
inbounds, err := j.inboundService.GetAllInbounds()
|
||||
if err != nil {
|
||||
logger.Warning("Failed to get inbounds for deletion:", err)
|
||||
return
|
||||
}
|
||||
|
||||
batchSize := 50 // clients in 1 batch
|
||||
restartNeeded := false
|
||||
|
||||
for _, ib := range inbounds {
|
||||
if ib.Tag != inboundTag {
|
||||
continue
|
||||
}
|
||||
clients, err := j.inboundService.GetClients(ib)
|
||||
if err != nil {
|
||||
logger.Warningf("Failed to get clients for inbound %s: %v", ib.Tag, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Collect clients for deletion
|
||||
toDelete := []model.Client{}
|
||||
for _, c := range clients {
|
||||
if _, ok := ldapEmails[c.Email]; !ok {
|
||||
toDelete = append(toDelete, c)
|
||||
}
|
||||
}
|
||||
|
||||
if len(toDelete) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for i := 0; i < len(toDelete); i += batchSize {
|
||||
end := min(i+batchSize, len(toDelete))
|
||||
batch := toDelete[i:end]
|
||||
|
||||
for _, c := range batch {
|
||||
nr, err := j.clientService.DetachByEmail(&j.inboundService, ib.Id, c.Email)
|
||||
if err != nil {
|
||||
logger.Warningf("Failed to delete client %s from inbound id=%d(tag=%s): %v",
|
||||
c.Email, ib.Id, ib.Tag, err)
|
||||
continue
|
||||
}
|
||||
logger.Infof("Deleted client %s from inbound id=%d(tag=%s)",
|
||||
c.Email, ib.Id, ib.Tag)
|
||||
if nr {
|
||||
restartNeeded = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if restartNeeded {
|
||||
j.xrayService.SetToNeedRestart()
|
||||
logger.Info("Xray restart scheduled after batch deletion")
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
func initLdapJobDB(t *testing.T) {
|
||||
t.Helper()
|
||||
dbDir := t.TempDir()
|
||||
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
}
|
||||
|
||||
func TestBuildClient_ConvertsDefaultTotalGBToBytes(t *testing.T) {
|
||||
j := NewLdapSyncJob()
|
||||
c := j.buildClient("user@example.com", 10, 0, 0)
|
||||
if want := int64(10) * 1024 * 1024 * 1024; c.TotalGB != want {
|
||||
t.Errorf("TotalGB = %d, want %d", c.TotalGB, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLdapCreateClients_AttachesToAllConfiguredInbounds(t *testing.T) {
|
||||
initLdapJobDB(t)
|
||||
db := database.GetDB()
|
||||
|
||||
tags := []string{"in-1080-tcp", "in-1081-tcp", "in-1082-tcp"}
|
||||
protocols := []model.Protocol{model.VLESS, model.Trojan, model.VLESS}
|
||||
inboundIds := make([]int, 0, len(tags))
|
||||
for i, tag := range tags {
|
||||
ib := &model.Inbound{
|
||||
UserId: 1,
|
||||
Tag: tag,
|
||||
Enable: true,
|
||||
Port: 42080 + i,
|
||||
Protocol: protocols[i],
|
||||
Settings: `{"clients": []}`,
|
||||
StreamSettings: `{"network":"tcp","security":"none"}`,
|
||||
}
|
||||
if err := db.Create(ib).Error; err != nil {
|
||||
t.Fatalf("create inbound %s: %v", tag, err)
|
||||
}
|
||||
inboundIds = append(inboundIds, ib.Id)
|
||||
}
|
||||
|
||||
j := NewLdapSyncJob()
|
||||
const email = "user@example.com"
|
||||
j.createClients([]model.Client{j.buildClient(email, 0, 0, 0)}, inboundIds, tags)
|
||||
|
||||
rec := &model.ClientRecord{}
|
||||
if err := db.Where("email = ?", email).First(rec).Error; err != nil {
|
||||
t.Fatalf("client record for %s not created: %v", email, err)
|
||||
}
|
||||
if rec.SubID == "" {
|
||||
t.Error("created LDAP client must carry a subId")
|
||||
}
|
||||
|
||||
clientSvc := &service.ClientService{}
|
||||
for i, id := range inboundIds {
|
||||
clients, err := clientSvc.ListForInbound(nil, id)
|
||||
if err != nil {
|
||||
t.Fatalf("ListForInbound(%s): %v", tags[i], err)
|
||||
}
|
||||
if len(clients) != 1 || clients[0].Email != email {
|
||||
t.Fatalf("inbound %s must carry exactly the LDAP client, got %d clients", tags[i], len(clients))
|
||||
}
|
||||
if clients[0].SubID != rec.SubID {
|
||||
t.Errorf("inbound %s client subId = %q, want the shared %q", tags[i], clients[0].SubID, rec.SubID)
|
||||
}
|
||||
}
|
||||
|
||||
trojanClients, err := clientSvc.ListForInbound(nil, inboundIds[1])
|
||||
if err != nil {
|
||||
t.Fatalf("ListForInbound(trojan): %v", err)
|
||||
}
|
||||
if trojanClients[0].Password == "" {
|
||||
t.Error("trojan inbound client must get a generated password")
|
||||
}
|
||||
vlessClients, err := clientSvc.ListForInbound(nil, inboundIds[0])
|
||||
if err != nil {
|
||||
t.Fatalf("ListForInbound(vless): %v", err)
|
||||
}
|
||||
if vlessClients[0].ID == "" {
|
||||
t.Error("vless inbound client must get a generated uuid")
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
@@ -72,6 +73,14 @@ func (j *NodeHeartbeatJob) Run() {
|
||||
}
|
||||
|
||||
func (j *NodeHeartbeatJob) probeOne(n *model.Node) {
|
||||
// Agent-managed nodes are heartbeated via the agent token, not the
|
||||
// legacy panel probe. Skip the probe so we don't clobber the
|
||||
// agent-driven online status.
|
||||
var agentCount int64
|
||||
_ = database.GetDB().Model(&model.AgentNode{}).Where("node_id = ?", n.Id).Count(&agentCount).Error
|
||||
if agentCount > 0 {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), nodeHeartbeatRequestTimeout)
|
||||
defer cancel()
|
||||
prevStatus := n.Status
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"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/service/outbound"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
// XrayTrafficJob collects and processes traffic statistics from Xray, updating the database and optionally informing external APIs.
|
||||
// XrayTrafficJob collects and processes traffic statistics from Xray, updating the database.
|
||||
type XrayTrafficJob struct {
|
||||
settingService service.SettingService
|
||||
xrayService service.XrayService
|
||||
@@ -54,15 +49,6 @@ func splitMovedClientTraffics(clientTraffics []*xray.ClientTraffic) ([]*xray.Cli
|
||||
return moved, emails, active
|
||||
}
|
||||
|
||||
const externalInformTimeout = 3 * time.Second
|
||||
|
||||
var externalInformClient = &fasthttp.Client{
|
||||
ReadTimeout: externalInformTimeout,
|
||||
WriteTimeout: externalInformTimeout,
|
||||
MaxIdleConnDuration: time.Minute,
|
||||
MaxConnsPerHost: 4,
|
||||
}
|
||||
|
||||
// NewXrayTrafficJob creates a new traffic collection job instance.
|
||||
func NewXrayTrafficJob() *XrayTrafficJob {
|
||||
return new(XrayTrafficJob)
|
||||
@@ -100,11 +86,6 @@ func (j *XrayTrafficJob) Run() {
|
||||
}
|
||||
websocket.BroadcastInvalidate(websocket.MessageTypeInbounds)
|
||||
}
|
||||
if ExternalTrafficInformEnable, err := j.settingService.GetExternalTrafficInformEnable(); ExternalTrafficInformEnable {
|
||||
j.informTrafficToExternalAPI(traffics, clientTraffics)
|
||||
} else if err != nil {
|
||||
logger.Warning("get ExternalTrafficInformEnable failed:", err)
|
||||
}
|
||||
if needRestart0 || needRestart1 {
|
||||
j.xrayService.SetToNeedRestart()
|
||||
}
|
||||
@@ -223,36 +204,3 @@ func (j *XrayTrafficJob) Run() {
|
||||
logger.Warning("get all outbounds for websocket failed:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (j *XrayTrafficJob) informTrafficToExternalAPI(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) {
|
||||
if len(inboundTraffics) == 0 && len(clientTraffics) == 0 {
|
||||
return
|
||||
}
|
||||
informURL, err := j.settingService.GetExternalTrafficInformURI()
|
||||
if err != nil {
|
||||
logger.Warning("get ExternalTrafficInformURI failed:", err)
|
||||
return
|
||||
}
|
||||
informURL, err = service.SanitizePublicHTTPURL(informURL, false)
|
||||
if err != nil {
|
||||
logger.Warning("ExternalTrafficInformURI blocked:", err)
|
||||
return
|
||||
}
|
||||
requestBody, err := json.Marshal(map[string]any{"clientTraffics": clientTraffics, "inboundTraffics": inboundTraffics})
|
||||
if err != nil {
|
||||
logger.Warning("parse client/inbound traffic failed:", err)
|
||||
return
|
||||
}
|
||||
request := fasthttp.AcquireRequest()
|
||||
defer fasthttp.ReleaseRequest(request)
|
||||
request.Header.SetMethod("POST")
|
||||
request.Header.SetContentType("application/json; charset=UTF-8")
|
||||
request.SetBody(requestBody)
|
||||
request.SetRequestURI(informURL)
|
||||
request.Header.SetConnectionClose()
|
||||
response := fasthttp.AcquireResponse()
|
||||
defer fasthttp.ReleaseResponse(response)
|
||||
if err := externalInformClient.DoTimeout(request, response, externalInformTimeout); err != nil {
|
||||
logger.Warning("POST ExternalTrafficInformURI failed:", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,408 +0,0 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"mime"
|
||||
"net"
|
||||
"net/mail"
|
||||
"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}
|
||||
}
|
||||
|
||||
// smtpConnectTimeout bounds the TCP dial. smtpDeadline bounds every SMTP
|
||||
// protocol step after the connection is up, so a server that accepts the socket
|
||||
// but then stalls cannot block the sender goroutine (and leak its socket) long
|
||||
// after the caller's own timeout has already fired. smtpDeadline is a var only
|
||||
// so tests can shorten it.
|
||||
const smtpConnectTimeout = 10 * time.Second
|
||||
|
||||
var smtpDeadline = 30 * time.Second
|
||||
|
||||
// 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()
|
||||
fromAddr, _ := s.settingService.GetSmtpFrom()
|
||||
fromName, _ := s.settingService.GetSmtpFromName()
|
||||
toStr, _ := s.settingService.GetSmtpTo()
|
||||
encryptionType, _ := s.settingService.GetSmtpEncryptionType()
|
||||
|
||||
from := fromAddr
|
||||
if from == "" {
|
||||
from = username
|
||||
}
|
||||
if from == "" {
|
||||
return fmt.Errorf("smtp from not configured")
|
||||
}
|
||||
from, fromName = resolveFrom(from, fromName)
|
||||
|
||||
recipients := parseRecipients(toStr)
|
||||
if len(recipients) == 0 {
|
||||
return fmt.Errorf("no recipients configured")
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(host, fmt.Sprintf("%d", port))
|
||||
msg := buildMessage(from, fromName, 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{s.sendPlain(addr, auth, from, recipients, msg, host)}
|
||||
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()
|
||||
fromAddr, _ := s.settingService.GetSmtpFrom()
|
||||
fromName, _ := s.settingService.GetSmtpFromName()
|
||||
toStr, _ := s.settingService.GetSmtpTo()
|
||||
encryptionType, _ := s.settingService.GetSmtpEncryptionType()
|
||||
|
||||
from := fromAddr
|
||||
if from == "" {
|
||||
from = username
|
||||
}
|
||||
if from == "" {
|
||||
return SMTPTestResult{false, "send", "smtpFromNotConfigured"}
|
||||
}
|
||||
from, fromName = resolveFrom(from, fromName)
|
||||
|
||||
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.Dialer{NetDialer: dialer, Config: &tls.Config{
|
||||
ServerName: host,
|
||||
InsecureSkipVerify: false,
|
||||
}}).DialContext(context.Background(), "tcp", addr)
|
||||
default:
|
||||
conn, err = dialer.Dial("tcp", addr)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return SMTPTestResult{false, "connect", classifySMTPError(err)}
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(smtpDeadline))
|
||||
|
||||
// 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, fromName, 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: smtpConnectTimeout}
|
||||
conn, err := (&tls.Dialer{NetDialer: dialer, Config: &tls.Config{
|
||||
ServerName: host,
|
||||
InsecureSkipVerify: false,
|
||||
}}).DialContext(context.Background(), "tcp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(smtpDeadline))
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
// sendPlain delivers over a plain TCP connection, opportunistically upgrading
|
||||
// via STARTTLS when the server advertises it (the behavior net/smtp.SendMail
|
||||
// gives the "starttls" and "none" transports). Unlike SendMail it dials with a
|
||||
// timeout and arms a connection deadline, so a server that never speaks or
|
||||
// stalls mid-protocol cannot block the sender goroutine past smtpDeadline.
|
||||
func (s *EmailService) sendPlain(addr string, auth smtp.Auth, from string, to []string, msg []byte, host string) error {
|
||||
conn, err := (&net.Dialer{Timeout: smtpConnectTimeout}).Dial("tcp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(smtpDeadline))
|
||||
|
||||
client, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if err = client.Hello("localhost"); err != nil {
|
||||
return err
|
||||
}
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
if err = client.StartTLS(&tls.Config{ServerName: host}); 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.SplitSeq(toStr, ",") {
|
||||
s = strings.TrimSpace(s)
|
||||
if s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildMessage assembles an RFC 5322 message. It emits the two mandatory
|
||||
// header fields (Date, From) plus Message-ID, so strict receivers such as Gmail
|
||||
// accept it and spam filters do not penalize a missing date or message id. The
|
||||
// From header is a proper name-addr ("Name" <addr>) via net/mail, and a
|
||||
// non-ASCII subject is RFC 2047 encoded.
|
||||
// headerSanitizer drops CR/LF so a crafted address or name cannot inject extra
|
||||
// header lines. Configured addresses are already validated at save time
|
||||
// (entity.AllSetting.CheckValid), this is defense in depth for buildMessage.
|
||||
var headerSanitizer = strings.NewReplacer("\r", "", "\n", "")
|
||||
|
||||
func resolveFrom(from, fromName string) (string, string) {
|
||||
parsed, err := mail.ParseAddress(from)
|
||||
if err != nil {
|
||||
return from, fromName
|
||||
}
|
||||
if fromName == "" {
|
||||
fromName = parsed.Name
|
||||
}
|
||||
return parsed.Address, fromName
|
||||
}
|
||||
|
||||
func buildMessage(fromAddr, fromName string, to []string, subject, body string) []byte {
|
||||
fromAddr = headerSanitizer.Replace(fromAddr)
|
||||
fromName = headerSanitizer.Replace(fromName)
|
||||
from := (&mail.Address{Name: fromName, Address: fromAddr}).String()
|
||||
|
||||
domain := "localhost"
|
||||
if at := strings.LastIndex(fromAddr, "@"); at >= 0 && at+1 < len(fromAddr) {
|
||||
domain = fromAddr[at+1:]
|
||||
}
|
||||
var token [16]byte
|
||||
_, _ = rand.Read(token[:])
|
||||
messageID := fmt.Sprintf("<%s@%s>", hex.EncodeToString(token[:]), domain)
|
||||
|
||||
var msg strings.Builder
|
||||
fmt.Fprintf(&msg, "Date: %s\r\n", time.Now().Format(time.RFC1123Z))
|
||||
fmt.Fprintf(&msg, "From: %s\r\n", from)
|
||||
fmt.Fprintf(&msg, "To: %s\r\n", strings.Join(to, ", "))
|
||||
fmt.Fprintf(&msg, "Message-ID: %s\r\n", messageID)
|
||||
fmt.Fprintf(&msg, "Subject: %s\r\n", mime.QEncoding.Encode("utf-8", subject))
|
||||
msg.WriteString("MIME-Version: 1.0\r\n")
|
||||
msg.WriteString("Content-Type: text/html; charset=utf-8\r\n")
|
||||
msg.WriteString("\r\n")
|
||||
msg.WriteString(body)
|
||||
return []byte(msg.String())
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSendPlainReturnsOnStalledServer(t *testing.T) {
|
||||
orig := smtpDeadline
|
||||
smtpDeadline = 300 * time.Millisecond
|
||||
t.Cleanup(func() { smtpDeadline = orig })
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
stall := make(chan struct{})
|
||||
defer close(stall)
|
||||
go func() {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
<-stall
|
||||
}()
|
||||
|
||||
s := &EmailService{}
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- s.sendPlain(ln.Addr().String(), nil, "from@example.com",
|
||||
[]string{"to@example.com"}, []byte("body"), "example.com")
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err == nil {
|
||||
t.Fatal("expected an error from a silent SMTP server, got nil")
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("sendPlain did not return on a stalled server within the deadline")
|
||||
}
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net"
|
||||
"net/mail"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
func TestBuildMessageIsRFC5322(t *testing.T) {
|
||||
raw := buildMessage("panel@example.com", "3x-ui", []string{"a@example.com", "b@example.com"}, "Тест", "<b>hi</b>")
|
||||
|
||||
msg, err := mail.ReadMessage(strings.NewReader(string(raw)))
|
||||
if err != nil {
|
||||
t.Fatalf("message does not parse as RFC 5322: %v", err)
|
||||
}
|
||||
|
||||
from, err := mail.ParseAddress(msg.Header.Get("From"))
|
||||
if err != nil {
|
||||
t.Fatalf("From header does not parse: %v", err)
|
||||
}
|
||||
if from.Name != "3x-ui" || from.Address != "panel@example.com" {
|
||||
t.Errorf("From = %q <%q>, want name %q addr %q", from.Name, from.Address, "3x-ui", "panel@example.com")
|
||||
}
|
||||
|
||||
if _, err := msg.Header.Date(); err != nil {
|
||||
t.Errorf("Date header missing or unparseable: %v", err)
|
||||
}
|
||||
|
||||
id := msg.Header.Get("Message-ID")
|
||||
if !strings.HasPrefix(id, "<") || !strings.HasSuffix(id, "@example.com>") {
|
||||
t.Errorf("Message-ID = %q, want <token@example.com>", id)
|
||||
}
|
||||
|
||||
subject, err := (&mime.WordDecoder{}).DecodeHeader(msg.Header.Get("Subject"))
|
||||
if err != nil {
|
||||
t.Fatalf("Subject does not decode: %v", err)
|
||||
}
|
||||
if subject != "Тест" {
|
||||
t.Errorf("Subject = %q, want %q", subject, "Тест")
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(msg.Body)
|
||||
if string(body) != "<b>hi</b>" {
|
||||
t.Errorf("body = %q, want %q", body, "<b>hi</b>")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMessageFromWithoutName(t *testing.T) {
|
||||
raw := buildMessage("panel@example.com", "", []string{"a@example.com"}, "s", "b")
|
||||
msg, err := mail.ReadMessage(strings.NewReader(string(raw)))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
from, err := mail.ParseAddress(msg.Header.Get("From"))
|
||||
if err != nil {
|
||||
t.Fatalf("From header does not parse: %v", err)
|
||||
}
|
||||
if from.Name != "" || from.Address != "panel@example.com" {
|
||||
t.Errorf("From = %q <%q>, want bare addr", from.Name, from.Address)
|
||||
}
|
||||
}
|
||||
|
||||
func startFakeSMTPServer(t *testing.T) (string, func() []string) {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
|
||||
var mu sync.Mutex
|
||||
var lines []string
|
||||
record := func(line string) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
lines = append(lines, line)
|
||||
}
|
||||
|
||||
go func() {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
reader := bufio.NewReader(conn)
|
||||
fmt.Fprint(conn, "220 fake ready\r\n")
|
||||
inData := false
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
record(line)
|
||||
if inData {
|
||||
if line == "." {
|
||||
inData = false
|
||||
fmt.Fprint(conn, "250 ok\r\n")
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(line, "DATA"):
|
||||
inData = true
|
||||
fmt.Fprint(conn, "354 send\r\n")
|
||||
case strings.HasPrefix(line, "QUIT"):
|
||||
fmt.Fprint(conn, "221 bye\r\n")
|
||||
return
|
||||
default:
|
||||
fmt.Fprint(conn, "250 ok\r\n")
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return ln.Addr().String(), func() []string {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return append([]string(nil), lines...)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendUsesBareAddressFromNameAddrSmtpFrom(t *testing.T) {
|
||||
if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
|
||||
addr, recordedLines := startFakeSMTPServer(t)
|
||||
host, portStr, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
settingService := service.SettingService{}
|
||||
mustSet := func(name string, err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatalf("set %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
mustSet("host", settingService.SetSmtpHost(host))
|
||||
mustSet("port", settingService.SetSmtpPort(port))
|
||||
mustSet("from", settingService.SetSmtpFrom("3x-ui Panel <panel@example.com>"))
|
||||
mustSet("to", settingService.SetSmtpTo("admin@example.com"))
|
||||
mustSet("encryption", settingService.SetSmtpEncryptionType("none"))
|
||||
|
||||
if err := NewEmailService(settingService).Send("subject", "<b>hi</b>"); err != nil {
|
||||
t.Fatalf("send: %v", err)
|
||||
}
|
||||
|
||||
var mailFrom, fromHeader string
|
||||
for _, line := range recordedLines() {
|
||||
if strings.HasPrefix(line, "MAIL FROM:") {
|
||||
mailFrom = line
|
||||
}
|
||||
if strings.HasPrefix(line, "From: ") {
|
||||
fromHeader = line
|
||||
}
|
||||
}
|
||||
if want := "MAIL FROM:<panel@example.com>"; mailFrom != want {
|
||||
t.Errorf("envelope sender = %q, want %q", mailFrom, want)
|
||||
}
|
||||
if want := `From: "3x-ui Panel" <panel@example.com>`; fromHeader != want {
|
||||
t.Errorf("from header = %q, want %q", fromHeader, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectionReportsMissingFrom(t *testing.T) {
|
||||
if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
|
||||
settingService := service.SettingService{}
|
||||
mustSet := func(name string, err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatalf("set %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
mustSet("host", settingService.SetSmtpHost("127.0.0.1"))
|
||||
mustSet("port", settingService.SetSmtpPort(1))
|
||||
mustSet("to", settingService.SetSmtpTo("admin@example.com"))
|
||||
mustSet("encryption", settingService.SetSmtpEncryptionType("none"))
|
||||
|
||||
got := NewEmailService(settingService).TestConnection()
|
||||
want := SMTPTestResult{Success: false, Stage: "send", Message: "smtpFromNotConfigured"}
|
||||
if got != want {
|
||||
t.Errorf("TestConnection() = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMessageStripsHeaderInjection(t *testing.T) {
|
||||
raw := buildMessage(
|
||||
"panel@example.com\r\nBcc: evil@example.com",
|
||||
"Name\r\nX-Evil: 1",
|
||||
[]string{"a@example.com"}, "s", "b",
|
||||
)
|
||||
msg, err := mail.ReadMessage(strings.NewReader(string(raw)))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if got := msg.Header.Get("Bcc"); got != "" {
|
||||
t.Errorf("injected Bcc header leaked: %q", got)
|
||||
}
|
||||
if got := msg.Header.Get("X-Evil"); got != "" {
|
||||
t.Errorf("injected X-Evil header leaked: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
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 on, err := s.settingService.GetSmtpEnable(); err != nil || !on {
|
||||
return
|
||||
}
|
||||
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.SplitSeq(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.EventMemoryHigh:
|
||||
if data, ok := e.Data.(*eventbus.SystemMetricData); ok {
|
||||
smtpMemory, err := s.settingService.GetSmtpMemory()
|
||||
if err != nil || smtpMemory <= 0 || data.Percent <= float64(smtpMemory) {
|
||||
return
|
||||
}
|
||||
subject = host + " " + i18n("tgbot.messages.memoryThreshold",
|
||||
"Percent=="+strconv.FormatFloat(data.Percent, 'f', 2, 64),
|
||||
"Threshold=="+fmt.Sprintf("%d", smtpMemory))
|
||||
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()
|
||||
}
|
||||
@@ -118,8 +118,8 @@ type NodeMutationRequest struct {
|
||||
Name string `json:"name" form:"name" validate:"required"`
|
||||
Remark string `json:"remark" form:"remark"`
|
||||
Scheme string `json:"scheme" form:"scheme" validate:"omitempty,oneof=http https"`
|
||||
Address string `json:"address" form:"address" validate:"required"`
|
||||
Port int `json:"port" form:"port" validate:"gte=1,lte=65535"`
|
||||
Address string `json:"address" form:"address"`
|
||||
Port int `json:"port" form:"port" validate:"gte=0,lte=65535"`
|
||||
BasePath string `json:"basePath" form:"basePath"`
|
||||
ApiToken *string `json:"apiToken,omitempty" form:"apiToken"`
|
||||
ClearApiToken bool `json:"clearApiToken,omitempty" form:"clearApiToken"`
|
||||
@@ -130,12 +130,26 @@ type NodeMutationRequest struct {
|
||||
InboundSyncMode string `json:"inboundSyncMode" form:"inboundSyncMode" validate:"omitempty,oneof=all selected"`
|
||||
InboundTags []string `json:"inboundTags" form:"inboundTags"`
|
||||
OutboundTag string `json:"outboundTag" form:"outboundTag"`
|
||||
// NodeType distinguishes traditional panel-to-panel nodes from lightweight
|
||||
// agent nodes. Agent nodes skip the panel reachability probe and the API
|
||||
// token requirement; an agent token is issued on creation instead.
|
||||
NodeType string `json:"nodeType,omitempty" form:"nodeType" validate:"omitempty,oneof=traditional agent"`
|
||||
}
|
||||
|
||||
func (r *NodeMutationRequest) validateCredentials(create bool) error {
|
||||
if r == nil {
|
||||
return common.NewError("node request is required")
|
||||
}
|
||||
// Agent nodes dial out to the master; address/port are not needed.
|
||||
// Traditional nodes still require them.
|
||||
if r.NodeType != "agent" {
|
||||
if strings.TrimSpace(r.Address) == "" {
|
||||
return common.NewError("address is required")
|
||||
}
|
||||
if r.Port < 1 || r.Port > 65535 {
|
||||
return common.NewError("port must be between 1 and 65535")
|
||||
}
|
||||
}
|
||||
if r.ApiToken != nil && r.ClearApiToken {
|
||||
return common.NewError("apiToken and clearApiToken are mutually exclusive")
|
||||
}
|
||||
@@ -152,7 +166,8 @@ func (r *NodeMutationRequest) validateCredentials(create bool) error {
|
||||
if r.ClearApiToken {
|
||||
return common.NewError("credentials cannot be cleared while creating a node")
|
||||
}
|
||||
if r.ApiToken == nil && r.TlsVerifyMode != "mtls" {
|
||||
// Agent nodes authenticate via agent token, not panel API token.
|
||||
if r.NodeType != "agent" && r.ApiToken == nil && r.TlsVerifyMode != "mtls" {
|
||||
return common.NewError("apiToken is required unless mtls is enabled")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,12 @@ package panel
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/xlzd/gotp"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
|
||||
ldaputil "github.com/mhsanaei/3x-ui/v3/internal/util/ldap"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
@@ -35,7 +33,7 @@ func (s *UserService) GetFirstUser() (*model.User, error) {
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *UserService) CheckUser(username string, password string, twoFactorCode string) (*model.User, error) {
|
||||
func (s *UserService) CheckUser(username string, password string) (*model.User, error) {
|
||||
db := database.GetDB()
|
||||
|
||||
user := &model.User{}
|
||||
@@ -52,54 +50,7 @@ func (s *UserService) CheckUser(username string, password string, twoFactorCode
|
||||
}
|
||||
|
||||
if !crypto.CheckPasswordHash(user.Password, password) {
|
||||
ldapEnabled, _ := s.settingService.GetLdapEnable()
|
||||
if !ldapEnabled {
|
||||
return nil, errors.New("invalid credentials")
|
||||
}
|
||||
|
||||
host, _ := s.settingService.GetLdapHost()
|
||||
port, _ := s.settingService.GetLdapPort()
|
||||
useTLS, _ := s.settingService.GetLdapUseTLS()
|
||||
skipVerify, _ := s.settingService.GetLdapInsecureSkipVerify()
|
||||
bindDN, _ := s.settingService.GetLdapBindDN()
|
||||
ldapPass, _ := s.settingService.GetLdapPassword()
|
||||
baseDN, _ := s.settingService.GetLdapBaseDN()
|
||||
userFilter, _ := s.settingService.GetLdapUserFilter()
|
||||
userAttr, _ := s.settingService.GetLdapUserAttr()
|
||||
|
||||
cfg := ldaputil.Config{
|
||||
Host: host,
|
||||
Port: port,
|
||||
UseTLS: useTLS,
|
||||
InsecureSkipVerify: skipVerify,
|
||||
BindDN: bindDN,
|
||||
Password: ldapPass,
|
||||
BaseDN: baseDN,
|
||||
UserFilter: userFilter,
|
||||
UserAttr: userAttr,
|
||||
}
|
||||
ok, err := ldaputil.AuthenticateUser(cfg, username, password)
|
||||
if err != nil || !ok {
|
||||
return nil, errors.New("invalid credentials")
|
||||
}
|
||||
}
|
||||
|
||||
twoFactorEnable, err := s.settingService.GetTwoFactorEnable()
|
||||
if err != nil {
|
||||
logger.Warning("check two factor err:", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if twoFactorEnable {
|
||||
twoFactorToken, err := s.settingService.GetTwoFactorToken()
|
||||
if err != nil {
|
||||
logger.Warning("check two factor token err:", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if gotp.NewDefaultTOTP(twoFactorToken).Now() != twoFactorCode {
|
||||
return nil, errors.New("invalid 2fa code")
|
||||
}
|
||||
return nil, errors.New("invalid credentials")
|
||||
}
|
||||
|
||||
return user, nil
|
||||
@@ -120,16 +71,6 @@ func (s *UserService) UpdateUser(id int, username string, password string) error
|
||||
return err
|
||||
}
|
||||
|
||||
twoFactorEnable, err := s.settingService.GetTwoFactorEnable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if twoFactorEnable {
|
||||
_ = s.settingService.SetTwoFactorEnable(false)
|
||||
_ = s.settingService.SetTwoFactorToken("")
|
||||
}
|
||||
|
||||
return db.Model(model.User{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]any{
|
||||
|
||||
+76
-390
@@ -15,7 +15,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/xlzd/gotp"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
@@ -55,125 +54,84 @@ var defaultValueMap = map[string]string{
|
||||
// cert are minted lazily on first use, and the node-side trust CA is pasted
|
||||
// in by the operator. Kept out of entity.AllSetting so private keys never
|
||||
// reach the settings UI/export.
|
||||
"nodeMtlsCaCertPem": "",
|
||||
"nodeMtlsCaKeyPem": "",
|
||||
"nodeMtlsClientCertPem": "",
|
||||
"nodeMtlsClientKeyPem": "",
|
||||
"nodeMtlsClientCertSha256": "",
|
||||
"nodeMtlsClientCAPem": "",
|
||||
"webBasePath": normalizeBasePath(getEnv("XUI_INIT_WEB_BASE_PATH", "/")),
|
||||
"sessionMaxAge": "360",
|
||||
"trustedProxyCIDRs": DefaultTrustedProxyCIDRs,
|
||||
"ipLimitAllowlist": "",
|
||||
"pageSize": "25",
|
||||
"expireDiff": "0",
|
||||
"trafficDiff": "0",
|
||||
"remarkTemplate": DefaultRemarkTemplate,
|
||||
"subShowIdentityOnAllLinks": "false",
|
||||
"timeLocation": "Local",
|
||||
"tgBotEnable": "false",
|
||||
"tgBotToken": "",
|
||||
"tgBotProxy": "",
|
||||
"tgBotAPIServer": "",
|
||||
"tgBotChatId": "",
|
||||
"tgRunTime": "@daily",
|
||||
"tgBotBackup": "false",
|
||||
"tgCpu": "80",
|
||||
"tgMemory": "80",
|
||||
"tgLang": "en-US",
|
||||
"twoFactorEnable": "false",
|
||||
"twoFactorToken": "",
|
||||
"subEnable": "true",
|
||||
"subJsonEnable": "false",
|
||||
"subJsonAutoDetect": "false",
|
||||
"subJsonAlwaysArray": "false",
|
||||
"subJsonUserAgentRegex": "",
|
||||
"subClashAutoDetect": "false",
|
||||
"subClashUserAgentRegex": "",
|
||||
"subTitle": "",
|
||||
"subSupportUrl": "",
|
||||
"subProfileUrl": "",
|
||||
"subAnnounce": "",
|
||||
"subEnableRouting": "false",
|
||||
"subRoutingRules": "",
|
||||
"subHideSettings": "false",
|
||||
"subIncyEnableRouting": "false",
|
||||
"subIncyRoutingRules": "",
|
||||
"subListen": "",
|
||||
"subPort": "2096",
|
||||
"subPath": "/sub/",
|
||||
"subDomain": "",
|
||||
"subCertFile": "",
|
||||
"subKeyFile": "",
|
||||
"subUpdates": "12",
|
||||
"subEncrypt": "true",
|
||||
"subURI": "",
|
||||
"subJsonPath": "/json/",
|
||||
"subJsonURI": "",
|
||||
"subClashEnable": "false",
|
||||
"subClashPath": "/clash/",
|
||||
"subClashURI": "",
|
||||
"subClashEnableRouting": "false",
|
||||
"subClashRules": "",
|
||||
"subJsonMux": "",
|
||||
"subJsonRules": "",
|
||||
"subJsonFinalMask": "",
|
||||
"subJsonObservatory": "",
|
||||
"subThemeDir": "",
|
||||
"datepicker": "gregorian",
|
||||
"warp": "",
|
||||
"warpUpdateInterval": "0",
|
||||
"nord": "",
|
||||
"pia": "",
|
||||
"externalTrafficInformEnable": "false",
|
||||
"externalTrafficInformURI": "",
|
||||
"restartXrayOnClientDisable": "true",
|
||||
"xrayOutboundTestUrl": "https://www.google.com/generate_204",
|
||||
"panelOutbound": "",
|
||||
"devChannelEnable": "false",
|
||||
|
||||
// LDAP defaults
|
||||
"ldapEnable": "false",
|
||||
"ldapHost": "",
|
||||
"ldapPort": "389",
|
||||
"ldapUseTLS": "false",
|
||||
"ldapInsecureSkipVerify": "false",
|
||||
"ldapBindDN": "",
|
||||
"ldapPassword": "",
|
||||
"ldapBaseDN": "",
|
||||
"ldapUserFilter": "(objectClass=person)",
|
||||
"ldapUserAttr": "mail",
|
||||
"ldapVlessField": "vless_enabled",
|
||||
"ldapSyncCron": "@every 1m",
|
||||
"ldapFlagField": "",
|
||||
"ldapTruthyValues": "true,1,yes,on",
|
||||
"ldapInvertFlag": "false",
|
||||
"ldapInboundTags": "",
|
||||
"ldapAutoCreate": "false",
|
||||
"ldapAutoDelete": "false",
|
||||
"ldapDefaultTotalGB": "0",
|
||||
"ldapDefaultExpiryDays": "0",
|
||||
"ldapDefaultLimitIP": "0",
|
||||
"nodeMtlsCaCertPem": "",
|
||||
"nodeMtlsCaKeyPem": "",
|
||||
"nodeMtlsClientCertPem": "",
|
||||
"nodeMtlsClientKeyPem": "",
|
||||
"nodeMtlsClientCertSha256": "",
|
||||
"nodeMtlsClientCAPem": "",
|
||||
"webBasePath": normalizeBasePath(getEnv("XUI_INIT_WEB_BASE_PATH", "/")),
|
||||
"sessionMaxAge": "360",
|
||||
"trustedProxyCIDRs": DefaultTrustedProxyCIDRs,
|
||||
"ipLimitAllowlist": "",
|
||||
"pageSize": "25",
|
||||
"expireDiff": "0",
|
||||
"trafficDiff": "0",
|
||||
"remarkTemplate": DefaultRemarkTemplate,
|
||||
"subShowIdentityOnAllLinks": "false",
|
||||
"timeLocation": "Local",
|
||||
"tgBotEnable": "false",
|
||||
"tgBotToken": "",
|
||||
"tgBotProxy": "",
|
||||
"tgBotAPIServer": "",
|
||||
"tgBotChatId": "",
|
||||
"tgRunTime": "@daily",
|
||||
"tgBotBackup": "false",
|
||||
"tgCpu": "80",
|
||||
"tgMemory": "80",
|
||||
"tgLang": "en-US",
|
||||
"subEnable": "true",
|
||||
"subJsonEnable": "false",
|
||||
"subJsonAutoDetect": "false",
|
||||
"subJsonAlwaysArray": "false",
|
||||
"subJsonUserAgentRegex": "",
|
||||
"subClashAutoDetect": "false",
|
||||
"subClashUserAgentRegex": "",
|
||||
"subTitle": "",
|
||||
"subSupportUrl": "",
|
||||
"subProfileUrl": "",
|
||||
"subAnnounce": "",
|
||||
"subEnableRouting": "false",
|
||||
"subRoutingRules": "",
|
||||
"subHideSettings": "false",
|
||||
"subIncyEnableRouting": "false",
|
||||
"subIncyRoutingRules": "",
|
||||
"subListen": "",
|
||||
"subPort": "2096",
|
||||
"subPath": "/sub/",
|
||||
"subDomain": "",
|
||||
"subCertFile": "",
|
||||
"subKeyFile": "",
|
||||
"subUpdates": "12",
|
||||
"subEncrypt": "true",
|
||||
"subURI": "",
|
||||
"subJsonPath": "/json/",
|
||||
"subJsonURI": "",
|
||||
"subClashEnable": "false",
|
||||
"subClashPath": "/clash/",
|
||||
"subClashURI": "",
|
||||
"subClashEnableRouting": "false",
|
||||
"subClashRules": "",
|
||||
"subJsonMux": "",
|
||||
"subJsonRules": "",
|
||||
"subJsonFinalMask": "",
|
||||
"subJsonObservatory": "",
|
||||
"subThemeDir": "",
|
||||
"datepicker": "gregorian",
|
||||
"warp": "",
|
||||
"warpUpdateInterval": "0",
|
||||
"nord": "",
|
||||
"pia": "",
|
||||
"restartXrayOnClientDisable": "true",
|
||||
"xrayOutboundTestUrl": "https://www.google.com/generate_204",
|
||||
"panelOutbound": "",
|
||||
"devChannelEnable": "false",
|
||||
|
||||
// Event bus — per-subscriber event filtering (empty = all disabled)
|
||||
"tgEnabledEvents": "login.attempt,cpu.high",
|
||||
"smtpEnabledEvents": "login.attempt,cpu.high",
|
||||
"smtpCpu": "80",
|
||||
"smtpMemory": "80",
|
||||
"tgEnabledEvents": "login.attempt,cpu.high",
|
||||
|
||||
// Consecutive failed observatory probes before an outbound.down event fires
|
||||
"outboundDownThreshold": "3",
|
||||
|
||||
// Email (SMTP) notifications
|
||||
"smtpEnable": "false",
|
||||
"smtpHost": "",
|
||||
"smtpPort": "587",
|
||||
"smtpUsername": "",
|
||||
"smtpPassword": "",
|
||||
"smtpFrom": "",
|
||||
"smtpFromName": "",
|
||||
"smtpTo": "",
|
||||
"smtpEncryptionType": "starttls", // no, starttls, tls
|
||||
}
|
||||
|
||||
// SettingService provides business logic for application settings management.
|
||||
@@ -271,19 +229,13 @@ func (s *SettingService) GetAllSettingView() (*entity.AllSettingView, error) {
|
||||
}
|
||||
view := &entity.AllSettingView{AllSetting: *allSetting}
|
||||
view.HasTgBotToken = secretConfigured(allSetting.TgBotToken)
|
||||
view.HasTwoFactorToken = secretConfigured(allSetting.TwoFactorToken)
|
||||
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
|
||||
}
|
||||
view.TgBotToken = ""
|
||||
view.TwoFactorToken = ""
|
||||
view.LdapPassword = ""
|
||||
view.SmtpPassword = ""
|
||||
return view, nil
|
||||
}
|
||||
|
||||
@@ -583,40 +535,6 @@ func (s *SettingService) GetTgLang() (string, error) {
|
||||
return s.getString("tgLang")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetTwoFactorEnable() (bool, error) {
|
||||
return s.getBool("twoFactorEnable")
|
||||
}
|
||||
|
||||
func (s *SettingService) SetTwoFactorEnable(value bool) error {
|
||||
return s.setBool("twoFactorEnable", value)
|
||||
}
|
||||
|
||||
func (s *SettingService) GetTwoFactorToken() (string, error) {
|
||||
return s.getString("twoFactorToken")
|
||||
}
|
||||
|
||||
func (s *SettingService) SetTwoFactorToken(value string) error {
|
||||
return s.setString("twoFactorToken", value)
|
||||
}
|
||||
|
||||
func (s *SettingService) VerifyTwoFactorCode(code string) error {
|
||||
enabled, err := s.GetTwoFactorEnable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !enabled {
|
||||
return nil
|
||||
}
|
||||
token, err := s.GetTwoFactorToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(token) == "" || !gotp.NewDefaultTOTP(token).Verify(strings.TrimSpace(code), time.Now().Unix()) {
|
||||
return common.NewError("invalid two factor code")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SettingService) GetPort() (int, error) {
|
||||
return s.getInt("webPort")
|
||||
}
|
||||
@@ -930,22 +848,6 @@ func (s *SettingService) SetPia(data string) error {
|
||||
return s.setString("pia", data)
|
||||
}
|
||||
|
||||
func (s *SettingService) GetExternalTrafficInformEnable() (bool, error) {
|
||||
return s.getBool("externalTrafficInformEnable")
|
||||
}
|
||||
|
||||
func (s *SettingService) SetExternalTrafficInformEnable(value bool) error {
|
||||
return s.setBool("externalTrafficInformEnable", value)
|
||||
}
|
||||
|
||||
func (s *SettingService) GetExternalTrafficInformURI() (string, error) {
|
||||
return s.getString("externalTrafficInformURI")
|
||||
}
|
||||
|
||||
func (s *SettingService) SetExternalTrafficInformURI(InformURI string) error {
|
||||
return s.setString("externalTrafficInformURI", InformURI)
|
||||
}
|
||||
|
||||
func (s *SettingService) GetRestartXrayOnClientDisable() (bool, error) {
|
||||
return s.getBool("restartXrayOnClientDisable")
|
||||
}
|
||||
@@ -983,91 +885,6 @@ func (s *SettingService) GetAccessLogEnable() (bool, error) {
|
||||
return (accessLogPath != "none" && accessLogPath != ""), nil
|
||||
}
|
||||
|
||||
// GetLdapEnable returns whether LDAP is enabled.
|
||||
func (s *SettingService) GetLdapEnable() (bool, error) {
|
||||
return s.getBool("ldapEnable")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetLdapHost() (string, error) {
|
||||
return s.getString("ldapHost")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetLdapPort() (int, error) {
|
||||
return s.getInt("ldapPort")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetLdapUseTLS() (bool, error) {
|
||||
return s.getBool("ldapUseTLS")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetLdapInsecureSkipVerify() (bool, error) {
|
||||
return s.getBool("ldapInsecureSkipVerify")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetLdapBindDN() (string, error) {
|
||||
return s.getString("ldapBindDN")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetLdapPassword() (string, error) {
|
||||
return s.getString("ldapPassword")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetLdapBaseDN() (string, error) {
|
||||
return s.getString("ldapBaseDN")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetLdapUserFilter() (string, error) {
|
||||
return s.getString("ldapUserFilter")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetLdapUserAttr() (string, error) {
|
||||
return s.getString("ldapUserAttr")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetLdapVlessField() (string, error) {
|
||||
return s.getString("ldapVlessField")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetLdapSyncCron() (string, error) {
|
||||
return s.getString("ldapSyncCron")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetLdapFlagField() (string, error) {
|
||||
return s.getString("ldapFlagField")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetLdapTruthyValues() (string, error) {
|
||||
return s.getString("ldapTruthyValues")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetLdapInvertFlag() (bool, error) {
|
||||
return s.getBool("ldapInvertFlag")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetLdapInboundTags() (string, error) {
|
||||
return s.getString("ldapInboundTags")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetLdapAutoCreate() (bool, error) {
|
||||
return s.getBool("ldapAutoCreate")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetLdapAutoDelete() (bool, error) {
|
||||
return s.getBool("ldapAutoDelete")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetLdapDefaultTotalGB() (int, error) {
|
||||
return s.getInt("ldapDefaultTotalGB")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetLdapDefaultExpiryDays() (int, error) {
|
||||
return s.getInt("ldapDefaultExpiryDays")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetLdapDefaultLimitIP() (int, error) {
|
||||
return s.getInt("ldapDefaultLimitIP")
|
||||
}
|
||||
|
||||
// Event bus — per-subscriber event filtering
|
||||
|
||||
func (s *SettingService) GetTgEnabledEvents() (string, error) {
|
||||
@@ -1078,104 +895,6 @@ 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) GetSmtpFrom() (string, error) {
|
||||
return s.getString("smtpFrom")
|
||||
}
|
||||
|
||||
func (s *SettingService) SetSmtpFrom(value string) error {
|
||||
return s.setString("smtpFrom", value)
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSmtpFromName() (string, error) {
|
||||
return s.getString("smtpFromName")
|
||||
}
|
||||
|
||||
func (s *SettingService) SetSmtpFromName(value string) error {
|
||||
return s.setString("smtpFromName", 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) GetSmtpMemory() (int, error) {
|
||||
return s.getInt("smtpMemory")
|
||||
}
|
||||
|
||||
func (s *SettingService) SetSmtpMemory(value int) error {
|
||||
return s.setInt("smtpMemory", value)
|
||||
}
|
||||
|
||||
// GetOutboundDownThreshold returns how many consecutive failed observatory
|
||||
// probes an outbound must accumulate before an outbound.down notification is
|
||||
// emitted. 1 preserves the legacy "notify on the first failed probe" behaviour.
|
||||
@@ -1191,9 +910,7 @@ func (s *SettingService) SetOutboundDownThreshold(value int) error {
|
||||
// flag, a blank submitted secret means "unchanged" (the field is always served
|
||||
// blank to the browser) and the stored value is preserved.
|
||||
type SecretClears struct {
|
||||
TgBotToken bool
|
||||
LdapPassword bool
|
||||
SmtpPassword bool
|
||||
TgBotToken bool
|
||||
}
|
||||
|
||||
func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting, clears SecretClears) error {
|
||||
@@ -1296,38 +1013,10 @@ func (s *SettingService) preserveRedactedSecrets(allSetting *entity.AllSetting,
|
||||
}
|
||||
allSetting.TgBotToken = value
|
||||
}
|
||||
if !clears.LdapPassword && strings.TrimSpace(allSetting.LdapPassword) == "" {
|
||||
value, err := s.GetLdapPassword()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
allSetting.LdapPassword = value
|
||||
}
|
||||
if allSetting.TwoFactorEnable && strings.TrimSpace(allSetting.TwoFactorToken) == "" {
|
||||
value, err := s.GetTwoFactorToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
allSetting.TwoFactorToken = value
|
||||
}
|
||||
if !clears.SmtpPassword && strings.TrimSpace(allSetting.SmtpPassword) == "" {
|
||||
value, err := s.GetSmtpPassword()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
allSetting.SmtpPassword = value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSettingsURLs(allSetting *entity.AllSetting) error {
|
||||
if allSetting.ExternalTrafficInformURI != "" {
|
||||
u, err := SanitizeHTTPURL(allSetting.ExternalTrafficInformURI)
|
||||
if err != nil {
|
||||
return common.NewError("external traffic inform URI is invalid:", err)
|
||||
}
|
||||
allSetting.ExternalTrafficInformURI = u
|
||||
}
|
||||
if allSetting.TgBotAPIServer != "" {
|
||||
u, err := SanitizeHTTPURL(allSetting.TgBotAPIServer)
|
||||
if err != nil {
|
||||
@@ -1366,7 +1055,7 @@ func validateRemoteRoutingURLSetting(name string, value *string) error {
|
||||
|
||||
func (s *SettingService) UpdateSecret(key string, value string) error {
|
||||
switch key {
|
||||
case "tgBotToken", "ldapPassword", "twoFactorToken":
|
||||
case "tgBotToken":
|
||||
return s.saveSetting(key, strings.TrimSpace(value))
|
||||
default:
|
||||
return common.NewError("secret key is not replaceable:", key)
|
||||
@@ -1499,10 +1188,7 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
|
||||
}
|
||||
|
||||
var factoryDefaultSecretKeys = map[string]bool{
|
||||
"tgBotToken": true,
|
||||
"twoFactorToken": true,
|
||||
"ldapPassword": true,
|
||||
"smtpPassword": true,
|
||||
"tgBotToken": true,
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -56,7 +56,6 @@ func TestGetFactoryDefaultsOmitsSensitiveMaterial(t *testing.T) {
|
||||
"xrayTemplateConfig",
|
||||
"tgBotToken",
|
||||
"twoFactorToken",
|
||||
"ldapPassword",
|
||||
"smtpPassword",
|
||||
} {
|
||||
t.Run(key, func(t *testing.T) {
|
||||
|
||||
@@ -142,6 +142,24 @@ func RemoveIndex(s []any, index int) []any {
|
||||
|
||||
// GetXrayConfig retrieves and builds the Xray configuration from settings and inbounds.
|
||||
func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
|
||||
return s.buildXrayConfig(nil, "", "")
|
||||
}
|
||||
|
||||
// GetXrayConfigForAgent builds the Xray configuration pushed to a lightweight
|
||||
// agent bound to nodeID: the panel template plus that node's enabled inbounds.
|
||||
// The agent's egress override (outbounds / routing rules JSON, as stored on its
|
||||
// AgentNode row) replaces the template defaults when set; otherwise the
|
||||
// template defaults are kept.
|
||||
func (s *XrayService) GetXrayConfigForAgent(nodeID int, outboundsJSON, routingRulesJSON string) (*xray.Config, error) {
|
||||
return s.buildXrayConfig(&nodeID, outboundsJSON, routingRulesJSON)
|
||||
}
|
||||
|
||||
// buildXrayConfig is the shared config builder. nodeID == nil builds the
|
||||
// local panel config (inbounds with no node); otherwise it builds the config
|
||||
// for the agent bound to that node. Local-only injections (subscription
|
||||
// outbounds, mtproto/amneziawg bridges, panel/node egresses) are skipped for
|
||||
// agents; agents get their egress from the per-agent override instead.
|
||||
func (s *XrayService) buildXrayConfig(nodeID *int, outboundsJSON, routingRulesJSON string) (*xray.Config, error) {
|
||||
templateConfig, err := s.settingService.GetXrayConfigTemplate()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -161,7 +179,11 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
|
||||
// the per-inbound lift below).
|
||||
xrayConfig.OutboundConfigs = liftOutboundsXhttpSessionIDKeys(xrayConfig.OutboundConfigs)
|
||||
|
||||
_, _, _ = s.inboundService.AddTraffic(nil, nil)
|
||||
// The local traffic flush only applies to the panel's own core; agent
|
||||
// traffic arrives through the agent traffic endpoint instead.
|
||||
if nodeID == nil {
|
||||
_, _, _ = s.inboundService.AddTraffic(nil, nil)
|
||||
}
|
||||
|
||||
inbounds, err := s.inboundService.GetAllInbounds()
|
||||
if err != nil {
|
||||
@@ -171,7 +193,11 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
|
||||
if !inbound.Enable {
|
||||
continue
|
||||
}
|
||||
if inbound.NodeID != nil {
|
||||
if nodeID == nil {
|
||||
if inbound.NodeID != nil {
|
||||
continue
|
||||
}
|
||||
} else if inbound.NodeID == nil || *inbound.NodeID != *nodeID {
|
||||
continue
|
||||
}
|
||||
if inbound.Protocol == model.MTProto || inbound.Protocol == model.AmneziaWG {
|
||||
@@ -260,8 +286,11 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
|
||||
mutated = true
|
||||
} else {
|
||||
_, hadClients := settings["clients"]
|
||||
mutated = hadClients || len(finalClients) > 0
|
||||
if mutated {
|
||||
// Don't clobber settings clients with an empty DB result;
|
||||
// the DB is the source of truth only when it has rows.
|
||||
if len(finalClients) > 0 {
|
||||
settings["clients"] = finalClients
|
||||
} else if !hadClients {
|
||||
settings["clients"] = finalClients
|
||||
}
|
||||
}
|
||||
@@ -348,6 +377,15 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
|
||||
xrayConfig.InboundConfigs = append(xrayConfig.InboundConfigs, *inboundConfig)
|
||||
}
|
||||
|
||||
if nodeID != nil {
|
||||
// Agent path: the panel is the only source of truth for egress, so the
|
||||
// agent's stored outbounds/routing rules replace the template defaults.
|
||||
if err := applyAgentEgress(xrayConfig, outboundsJSON, routingRulesJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return xrayConfig, nil
|
||||
}
|
||||
|
||||
// Merge subscription-derived outbounds (if any) into the final outbounds array.
|
||||
// These are additive: each subscription is placed before or after the template
|
||||
// outbounds based on its Prepend flag, ordered by Priority. Tags assigned by the
|
||||
@@ -410,6 +448,90 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
|
||||
return xrayConfig, nil
|
||||
}
|
||||
|
||||
// agentAPIRoutingRule keeps the xray stats API reachable on agent configs.
|
||||
// It is injected when the agent's routing rules don't already carry one, so a
|
||||
// panel-managed egress can never accidentally cut off traffic reporting.
|
||||
var agentAPIRoutingRule = map[string]any{
|
||||
"type": "field",
|
||||
"inboundTag": []any{"api"},
|
||||
"outboundTag": "api",
|
||||
}
|
||||
|
||||
// applyAgentEgress replaces the template's outbounds and routing rules with the
|
||||
// agent's stored egress configuration. Empty inputs keep the template
|
||||
// defaults. The stats-API routing rule is always ensured.
|
||||
func applyAgentEgress(cfg *xray.Config, outboundsJSON, routingRulesJSON string) error {
|
||||
if outboundsJSON != "" {
|
||||
var outbounds []any
|
||||
if err := json.Unmarshal([]byte(outboundsJSON), &outbounds); err != nil {
|
||||
return fmt.Errorf("invalid agent outbounds JSON: %w", err)
|
||||
}
|
||||
raw, err := json.Marshal(outbounds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.OutboundConfigs = raw
|
||||
}
|
||||
|
||||
var routing map[string]any
|
||||
if len(cfg.RouterConfig) > 0 {
|
||||
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
|
||||
return fmt.Errorf("invalid template routing JSON: %w", err)
|
||||
}
|
||||
} else {
|
||||
routing = map[string]any{}
|
||||
}
|
||||
if routingRulesJSON != "" {
|
||||
var rules []any
|
||||
if err := json.Unmarshal([]byte(routingRulesJSON), &rules); err != nil {
|
||||
return fmt.Errorf("invalid agent routing rules JSON: %w", err)
|
||||
}
|
||||
routing["rules"] = rules
|
||||
}
|
||||
routing["rules"] = ensureAPIRoutingRule(routing["rules"])
|
||||
raw, err := json.Marshal(routing)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.RouterConfig = raw
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureAPIRoutingRule prepends the stats-API rule when no rule already
|
||||
// matches the "api" inbound tag.
|
||||
func ensureAPIRoutingRule(rules any) []any {
|
||||
list, _ := rules.([]any)
|
||||
for _, r := range list {
|
||||
m, ok := r.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, tag := range inboundTagsOf(m["inboundTag"]) {
|
||||
if tag == "api" {
|
||||
return list
|
||||
}
|
||||
}
|
||||
}
|
||||
return append([]any{agentAPIRoutingRule}, list...)
|
||||
}
|
||||
|
||||
func inboundTagsOf(v any) []string {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return []string{t}
|
||||
case []any:
|
||||
var out []string
|
||||
for _, e := range t {
|
||||
if s, ok := e.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// PanelEgressInboundTag is the tag of the loopback SOCKS inbound injected into
|
||||
// the generated config when a panel outbound is configured. The panel's own
|
||||
// HTTP clients dial through it to egress via the chosen outbound.
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
jsonutil "github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
func testAgentCfg() *xray.Config {
|
||||
return &xray.Config{
|
||||
OutboundConfigs: jsonutil.RawMessage(`[{"tag":"direct","protocol":"freedom"},{"tag":"blocked","protocol":"blackhole"}]`),
|
||||
RouterConfig: jsonutil.RawMessage(`{"rules":[
|
||||
{"type":"field","inboundTag":["api"],"outboundTag":"api"},
|
||||
{"type":"field","ip":["geoip:private"],"outboundTag":"blocked"}
|
||||
]}`),
|
||||
}
|
||||
}
|
||||
|
||||
func agentRulesOf(t *testing.T, cfg *xray.Config) []any {
|
||||
t.Helper()
|
||||
var routing map[string]any
|
||||
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
|
||||
t.Fatalf("unmarshal routing: %v", err)
|
||||
}
|
||||
rules, _ := routing["rules"].([]any)
|
||||
return rules
|
||||
}
|
||||
|
||||
func hasAPIRule(rules []any) bool {
|
||||
for _, r := range rules {
|
||||
m, ok := r.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, tag := range inboundTagsOf(m["inboundTag"]) {
|
||||
if tag == "api" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestApplyAgentEgressOverride(t *testing.T) {
|
||||
cfg := testAgentCfg()
|
||||
outbounds := `[{"tag":"PPFL3-L1","protocol":"socks","settings":{"servers":[{"address":"10.240.64.2","port":23183}]}}]`
|
||||
rules := `[{"type":"field","inboundTag":["in-18692-tcp"],"outboundTag":"PPFL3-L1"}]`
|
||||
if err := applyAgentEgress(cfg, outbounds, rules); err != nil {
|
||||
t.Fatalf("applyAgentEgress: %v", err)
|
||||
}
|
||||
var obs []any
|
||||
if err := json.Unmarshal(cfg.OutboundConfigs, &obs); err != nil {
|
||||
t.Fatalf("unmarshal outbounds: %v", err)
|
||||
}
|
||||
if len(obs) != 1 || obs[0].(map[string]any)["tag"] != "PPFL3-L1" {
|
||||
t.Fatalf("outbounds not replaced: %s", cfg.OutboundConfigs)
|
||||
}
|
||||
rs := agentRulesOf(t, cfg)
|
||||
if len(rs) != 2 {
|
||||
t.Fatalf("expected 2 rules (custom + api), got %d", len(rs))
|
||||
}
|
||||
if !hasAPIRule(rs) {
|
||||
t.Fatal("api routing rule missing after override")
|
||||
}
|
||||
// The api rule must come first so it wins over user rules.
|
||||
first, _ := rs[0].(map[string]any)
|
||||
if first["outboundTag"] != "api" {
|
||||
t.Fatal("api rule should be prepended")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAgentEgressKeepsTemplate(t *testing.T) {
|
||||
cfg := testAgentCfg()
|
||||
if err := applyAgentEgress(cfg, "", ""); err != nil {
|
||||
t.Fatalf("applyAgentEgress: %v", err)
|
||||
}
|
||||
var obs []any
|
||||
if err := json.Unmarshal(cfg.OutboundConfigs, &obs); err != nil {
|
||||
t.Fatalf("unmarshal outbounds: %v", err)
|
||||
}
|
||||
if len(obs) != 2 {
|
||||
t.Fatalf("template outbounds should be kept, got %d", len(obs))
|
||||
}
|
||||
if !hasAPIRule(agentRulesOf(t, cfg)) {
|
||||
t.Fatal("api routing rule missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAgentEgressRejectsBadJSON(t *testing.T) {
|
||||
if err := applyAgentEgress(testAgentCfg(), "not-json", "[]"); err == nil {
|
||||
t.Fatal("expected error for bad outbounds JSON")
|
||||
}
|
||||
if err := applyAgentEgress(testAgentCfg(), "[]", `{"not":"array"}`); err == nil {
|
||||
t.Fatal("expected error for non-array routing rules")
|
||||
}
|
||||
}
|
||||
@@ -1179,7 +1179,19 @@
|
||||
"pinnedCertPlaceholder": "base64 or hex SHA-256",
|
||||
"fetchPin": "Fetch",
|
||||
"pinFetched": "Fetched the node's current certificate",
|
||||
"pinFetchFailed": "Could not fetch the certificate"
|
||||
"pinFetchFailed": "Could not fetch the certificate",
|
||||
"nodeType": "Node Type",
|
||||
"nodeTypeTraditional": "Traditional Panel Node",
|
||||
"nodeTypeAgent": "Agent Lightweight Node",
|
||||
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
|
||||
"agentTokenTitle": "Agent Node Setup",
|
||||
"agentTokenCreated": "Node {{name}} created successfully",
|
||||
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
|
||||
"agentTokenInstructions": "The command downloads the installer, configures the master URL and token. The node comes online in ~30s.",
|
||||
"agentTokenCopied": "Install command copied",
|
||||
"agentTokenCopyFailed": "Copy failed, please copy manually",
|
||||
"copyToken": "Copy Install Command",
|
||||
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):"
|
||||
},
|
||||
"settings": {
|
||||
"defaultTag": "Default",
|
||||
@@ -2249,4 +2261,4 @@
|
||||
"statusDown": "DOWN",
|
||||
"statusUp": "UP"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1061,7 +1061,19 @@
|
||||
"pinnedCertPlaceholder": "base64 或 hex 的 SHA-256",
|
||||
"fetchPin": "获取",
|
||||
"pinFetched": "已获取节点当前证书",
|
||||
"pinFetchFailed": "无法获取证书"
|
||||
"pinFetchFailed": "无法获取证书",
|
||||
"nodeType": "节点类型",
|
||||
"nodeTypeTraditional": "传统面板节点",
|
||||
"nodeTypeAgent": "Agent 轻量节点",
|
||||
"agentHint": "Agent 节点由节点机主动连接主控,无需填写地址、端口和 API 令牌。创建后会生成安装命令。",
|
||||
"agentTokenTitle": "Agent 节点安装",
|
||||
"agentTokenCreated": "节点 {{name}} 创建成功",
|
||||
"agentTokenHint": "复制下面的命令,在节点机上运行即可完成安装。此 token 只显示一次!",
|
||||
"agentTokenInstructions": "命令会自动下载安装脚本、配置主控地址和 token,约 30 秒后节点上线。",
|
||||
"agentTokenCopied": "安装命令已复制",
|
||||
"agentTokenCopyFailed": "复制失败,请手动复制",
|
||||
"copyToken": "复制安装命令",
|
||||
"agentMasterUrlHint": "主控地址(agent 用来连接主控,如有域名或反代请改成对外地址):"
|
||||
},
|
||||
"settings": {
|
||||
"defaultTag": "默认",
|
||||
@@ -2249,4 +2261,4 @@
|
||||
"statusDown": "断开",
|
||||
"statusUp": "恢复"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,6 @@ 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"
|
||||
@@ -372,17 +371,6 @@ func (s *Server) startTask(restartXray bool, loc *time.Location) {
|
||||
// Check monthly reset days at midnight
|
||||
_, _ = s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("monthly", loc))
|
||||
|
||||
// LDAP sync scheduling
|
||||
if ldapEnabled, _ := s.settingService.GetLdapEnable(); ldapEnabled {
|
||||
runtime, err := s.settingService.GetLdapSyncCron()
|
||||
if err != nil || runtime == "" {
|
||||
runtime = "@every 1m"
|
||||
}
|
||||
j := job.NewLdapSyncJob()
|
||||
// job has zero-value services with method receivers that read settings on demand
|
||||
_, _ = s.cron.AddJob(runtime, j)
|
||||
}
|
||||
|
||||
// Telegram-bot–dependent jobs: periodic stats report + callback-hash cleanup.
|
||||
isTgbotenabled, err := s.settingService.GetTgbotEnabled()
|
||||
if (err == nil) && isTgbotenabled {
|
||||
@@ -443,13 +431,6 @@ func (s *Server) cpuAlarmWanted() bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if on, _ := s.settingService.GetSmtpEnable(); on {
|
||||
events, _ := s.settingService.GetSmtpEnabledEvents()
|
||||
cpu, _ := s.settingService.GetSmtpCpu()
|
||||
if wants(events, cpu) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -473,13 +454,6 @@ func (s *Server) memoryAlarmWanted() bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if on, _ := s.settingService.GetSmtpEnable(); on {
|
||||
events, _ := s.settingService.GetSmtpEnabledEvents()
|
||||
mem, _ := s.settingService.GetSmtpMemory()
|
||||
if wants(events, mem) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -627,14 +601,6 @@ func (s *Server) start(restartXray bool, startTgBot bool) (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() {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"syscall"
|
||||
_ "unsafe"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/agent"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/crypto/nodetoken"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
@@ -66,6 +67,44 @@ func initNodeTokenCrypto() error {
|
||||
}
|
||||
|
||||
// runWebServer initializes and starts the web server for the 3x-ui panel.
|
||||
func runAgent(args []string) {
|
||||
agentCmd := flag.NewFlagSet("agent", flag.ExitOnError)
|
||||
var configPath string
|
||||
agentCmd.StringVar(&configPath, "config", "/etc/x-ui/agent.json", "Path to agent config file")
|
||||
agentCmd.Parse(args)
|
||||
|
||||
// Agent mode always uses the panel's directory layout for the xray binary,
|
||||
// geo files and config.json. Scoped to agent mode only so the panel's own
|
||||
// relative "bin" default is untouched.
|
||||
if os.Getenv("XUI_BIN_FOLDER") == "" {
|
||||
_ = os.Setenv("XUI_BIN_FOLDER", agent.DefaultBinFolder)
|
||||
}
|
||||
|
||||
cfg, err := agent.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load agent config from %s: %v", configPath, err)
|
||||
}
|
||||
|
||||
// Initialize logger
|
||||
logger.InitLogger(logging.INFO)
|
||||
log.Printf("Starting 3x-ui agent (node: %s)", cfg.NodeName)
|
||||
|
||||
ag := agent.New(cfg)
|
||||
|
||||
// Handle shutdown signals
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-sigCh
|
||||
log.Println("Shutting down agent...")
|
||||
ag.Stop()
|
||||
}()
|
||||
|
||||
if err := ag.Run(); err != nil {
|
||||
log.Fatalf("Agent error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func runWebServer() {
|
||||
log.Printf("Starting %v %v", config.GetName(), config.GetPanelVersion())
|
||||
|
||||
@@ -357,8 +396,8 @@ func encryptNodeTokens() {
|
||||
fmt.Printf("node-token migration complete: %d re-encrypted, %d already current/skipped\n", changed, skipped)
|
||||
}
|
||||
|
||||
// updateSetting updates various panel settings including port, credentials, base path, listen IP, and two-factor authentication.
|
||||
func updateSetting(port int, username string, password string, webBasePath string, listenIP string, resetTwoFactor bool) error {
|
||||
// updateSetting updates various panel settings including port, credentials, base path, and listen IP.
|
||||
func updateSetting(port int, username string, password string, webBasePath string, listenIP string) error {
|
||||
err := database.InitDB(config.GetDBPath())
|
||||
if err != nil {
|
||||
fmt.Println("Database initialization failed:", err)
|
||||
@@ -395,17 +434,6 @@ func updateSetting(port int, username string, password string, webBasePath strin
|
||||
}
|
||||
}
|
||||
|
||||
if resetTwoFactor {
|
||||
err := settingService.SetTwoFactorEnable(false)
|
||||
|
||||
if err != nil {
|
||||
fmt.Println("Failed to reset two-factor authentication:", err)
|
||||
} else {
|
||||
_ = settingService.SetTwoFactorToken("")
|
||||
fmt.Println("Two-factor authentication reset successfully")
|
||||
}
|
||||
}
|
||||
|
||||
if listenIP != "" {
|
||||
err := settingService.SetListen(listenIP)
|
||||
if err != nil {
|
||||
@@ -605,7 +633,6 @@ func main() {
|
||||
var show bool
|
||||
var getCert bool
|
||||
var getApiToken bool
|
||||
var resetTwoFactor bool
|
||||
settingCmd.BoolVar(&reset, "reset", false, "Reset all settings")
|
||||
settingCmd.BoolVar(&show, "show", false, "Display current settings")
|
||||
settingCmd.IntVar(&port, "port", 0, "Set panel port number")
|
||||
@@ -613,7 +640,6 @@ func main() {
|
||||
settingCmd.StringVar(&password, "password", "", "Set login password")
|
||||
settingCmd.StringVar(&webBasePath, "webBasePath", "", "Set base path for Panel")
|
||||
settingCmd.StringVar(&listenIP, "listenIP", "", "set panel listenIP IP")
|
||||
settingCmd.BoolVar(&resetTwoFactor, "resetTwoFactor", false, "Reset two-factor authentication settings")
|
||||
settingCmd.BoolVar(&getListen, "getListen", false, "Display current panel listenIP IP")
|
||||
settingCmd.BoolVar(&getCert, "getCert", false, "Display current certificate settings")
|
||||
settingCmd.BoolVar(&getApiToken, "getApiToken", false, "Display current API token")
|
||||
@@ -644,6 +670,8 @@ func main() {
|
||||
return
|
||||
}
|
||||
runWebServer()
|
||||
case "agent":
|
||||
runAgent(os.Args[2:])
|
||||
case "migrate":
|
||||
migrateDb()
|
||||
case "encrypt-tokens":
|
||||
@@ -693,7 +721,7 @@ func main() {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err = updateSetting(port, username, password, webBasePath, listenIP, resetTwoFactor); err != nil {
|
||||
if err = updateSetting(port, username, password, webBasePath, listenIP); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -742,6 +770,7 @@ func commandHelp() string {
|
||||
return `
|
||||
Commands:
|
||||
run run web panel
|
||||
agent run in agent mode (lightweight node, no web panel)
|
||||
migrate migrate from other/old x-ui
|
||||
migrate-db SQLite <-> .dump (--dump/--restore) or copy into PostgreSQL (--dsn)
|
||||
encrypt-tokens encrypt node bearer tokens with the configured active key
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/bin/sh /etc/rc.common
|
||||
# 3x-ui node agent procd init script (OpenWrt)
|
||||
# Install: cp x-ui-agent /etc/init.d/x-ui-agent && chmod +x /etc/init.d/x-ui-agent
|
||||
# /etc/init.d/x-ui-agent enable && /etc/init.d/x-ui-agent start
|
||||
|
||||
USE_PROCD=1
|
||||
START=99
|
||||
STOP=10
|
||||
|
||||
start_service() {
|
||||
procd_open_instance x-ui-agent
|
||||
procd_set_param command /usr/local/x-ui/x-ui agent --config /etc/x-ui/agent.json
|
||||
procd_set_param env XUI_BIN_FOLDER=/usr/local/x-ui/bin
|
||||
procd_set_param respawn 3600 5 5
|
||||
procd_set_param stdout 1
|
||||
procd_set_param stderr 1
|
||||
procd_close_instance
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[Unit]
|
||||
Description=3x-ui node agent (lightweight, no web panel)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
Environment=XUI_BIN_FOLDER=/usr/local/x-ui/bin
|
||||
ExecStart=/usr/local/x-ui/x-ui agent --config /etc/x-ui/agent.json
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
LimitNOFILE=1048576
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Reference in New Issue
Block a user