3x-ui agent: 轻量节点 agent 化
- Agent 通过 token 向主控注册,心跳/拉取 xray 配置/上报流量 - 主控按节点下发完整 xray 配置(含出站+路由),agent 只应用不自建 - Agent 心跳同步更新节点在线状态;后台探针跳过 agent 托管节点 - 「添加节点」支持 Agent 类型,自动生成 token 并弹窗展示 - VLESS/SS2022 入站;同机测试支持 api_port 隔离 - 新增 install-master.sh / install-agent.sh 一键安装脚本 - systemd + OpenWrt procd 服务文件,docs/AGENT.md
This commit is contained in:
+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
+75
@@ -0,0 +1,75 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 3x-ui-agent 节点一键安装脚本
|
||||||
|
# 用法: bash install-agent.sh --master http://MASTER_IP:2053 --token YOUR_AGENT_TOKEN [--name my-node]
|
||||||
|
# 要求: Debian/Ubuntu/CentOS amd64, 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)
|
||||||
|
[[ "$ARCH" != "x86_64" && "$ARCH" != "amd64" ]] && error "只支持 amd64, 当前: $ARCH"
|
||||||
|
|
||||||
|
# 二进制下载地址(GitHub release,替换为实际地址)
|
||||||
|
XUI_URL="${XUI_URL:-https://github.com/YOUR_REPO/3x-ui-agent/releases/latest/download/x-ui-linux-amd64}"
|
||||||
|
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
|
||||||
|
curl -fL -o Xray-linux-64.zip "https://github.com/XTLS/Xray-core/releases/download/v${XRAY_VER}/Xray-linux-64.zip" || error "xray 下载失败"
|
||||||
|
unzip -o -q Xray-linux-64.zip xray && mv xray xray-linux-amd64 && chmod +x xray-linux-amd64
|
||||||
|
rm -f Xray-linux-64.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
+52
@@ -0,0 +1,52 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 3x-ui-agent 主控一键安装脚本
|
||||||
|
# 用法: bash install-master.sh
|
||||||
|
# 要求: Debian/Ubuntu/CentOS amd64, 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)
|
||||||
|
[[ "$ARCH" != "x86_64" && "$ARCH" != "amd64" ]] && error "只支持 amd64, 当前: $ARCH"
|
||||||
|
|
||||||
|
# 二进制下载地址(GitHub release,替换为实际地址)
|
||||||
|
XUI_URL="${XUI_URL:-https://github.com/YOUR_REPO/3x-ui-agent/releases/latest/download/x-ui-linux-amd64}"
|
||||||
|
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
|
||||||
|
curl -fL -o Xray-linux-64.zip "https://github.com/XTLS/Xray-core/releases/download/v${XRAY_VER}/Xray-linux-64.zip" || error "xray 下载失败"
|
||||||
|
unzip -o -q Xray-linux-64.zip xray && mv xray xray-linux-amd64 && chmod +x xray-linux-amd64
|
||||||
|
rm -f Xray-linux-64.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
|
||||||
|
}
|
||||||
@@ -85,6 +85,7 @@ func allModels() []any {
|
|||||||
&model.ClientGlobalTraffic{},
|
&model.ClientGlobalTraffic{},
|
||||||
&model.OutboundSubscription{},
|
&model.OutboundSubscription{},
|
||||||
&model.SubBalancer{},
|
&model.SubBalancer{},
|
||||||
|
&model.AgentNode{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ func migrationModels() []any {
|
|||||||
&model.ClientGlobalTraffic{},
|
&model.ClientGlobalTraffic{},
|
||||||
&model.OutboundSubscription{},
|
&model.OutboundSubscription{},
|
||||||
&model.SubBalancer{},
|
&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"`
|
||||||
|
}
|
||||||
@@ -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
|
inboundController *InboundController
|
||||||
serverController *ServerController
|
serverController *ServerController
|
||||||
nodeController *NodeController
|
nodeController *NodeController
|
||||||
|
agentController *AgentController
|
||||||
hostController *HostController
|
hostController *HostController
|
||||||
settingController *SettingController
|
settingController *SettingController
|
||||||
xraySettingController *XraySettingController
|
xraySettingController *XraySettingController
|
||||||
@@ -191,6 +192,21 @@ func (a *APIController) initRouter(g *gin.RouterGroup) {
|
|||||||
nodes := api.Group("/nodes")
|
nodes := api.Group("/nodes")
|
||||||
a.nodeController = NewNodeController(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 — per-inbound override endpoints for subscription links
|
||||||
hosts := api.Group("/hosts")
|
hosts := api.Group("/hosts")
|
||||||
a.hostController = NewHostController(hosts)
|
a.hostController = NewHostController(hosts)
|
||||||
|
|||||||
@@ -144,7 +144,9 @@ func (a *NodeController) add(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
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 {
|
if err := a.ensureReachable(c, n, 0); err != nil {
|
||||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
|
||||||
return
|
return
|
||||||
@@ -164,6 +166,22 @@ func (a *NodeController) add(c *gin.Context) {
|
|||||||
return
|
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)
|
jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.add"), view, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"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/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
|
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||||
@@ -72,6 +73,14 @@ func (j *NodeHeartbeatJob) Run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (j *NodeHeartbeatJob) probeOne(n *model.Node) {
|
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)
|
ctx, cancel := context.WithTimeout(context.Background(), nodeHeartbeatRequestTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
prevStatus := n.Status
|
prevStatus := n.Status
|
||||||
|
|||||||
@@ -130,6 +130,10 @@ type NodeMutationRequest struct {
|
|||||||
InboundSyncMode string `json:"inboundSyncMode" form:"inboundSyncMode" validate:"omitempty,oneof=all selected"`
|
InboundSyncMode string `json:"inboundSyncMode" form:"inboundSyncMode" validate:"omitempty,oneof=all selected"`
|
||||||
InboundTags []string `json:"inboundTags" form:"inboundTags"`
|
InboundTags []string `json:"inboundTags" form:"inboundTags"`
|
||||||
OutboundTag string `json:"outboundTag" form:"outboundTag"`
|
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 {
|
func (r *NodeMutationRequest) validateCredentials(create bool) error {
|
||||||
@@ -152,7 +156,8 @@ func (r *NodeMutationRequest) validateCredentials(create bool) error {
|
|||||||
if r.ClearApiToken {
|
if r.ClearApiToken {
|
||||||
return common.NewError("credentials cannot be cleared while creating a node")
|
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")
|
return common.NewError("apiToken is required unless mtls is enabled")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,6 +142,24 @@ func RemoveIndex(s []any, index int) []any {
|
|||||||
|
|
||||||
// GetXrayConfig retrieves and builds the Xray configuration from settings and inbounds.
|
// GetXrayConfig retrieves and builds the Xray configuration from settings and inbounds.
|
||||||
func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
|
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()
|
templateConfig, err := s.settingService.GetXrayConfigTemplate()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -161,7 +179,11 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
|
|||||||
// the per-inbound lift below).
|
// the per-inbound lift below).
|
||||||
xrayConfig.OutboundConfigs = liftOutboundsXhttpSessionIDKeys(xrayConfig.OutboundConfigs)
|
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()
|
inbounds, err := s.inboundService.GetAllInbounds()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -171,7 +193,11 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
|
|||||||
if !inbound.Enable {
|
if !inbound.Enable {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if inbound.NodeID != nil {
|
if nodeID == nil {
|
||||||
|
if inbound.NodeID != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else if inbound.NodeID == nil || *inbound.NodeID != *nodeID {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if inbound.Protocol == model.MTProto || inbound.Protocol == model.AmneziaWG {
|
if inbound.Protocol == model.MTProto || inbound.Protocol == model.AmneziaWG {
|
||||||
@@ -260,8 +286,11 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
|
|||||||
mutated = true
|
mutated = true
|
||||||
} else {
|
} else {
|
||||||
_, hadClients := settings["clients"]
|
_, hadClients := settings["clients"]
|
||||||
mutated = hadClients || len(finalClients) > 0
|
// Don't clobber settings clients with an empty DB result;
|
||||||
if mutated {
|
// 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
|
settings["clients"] = finalClients
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -348,6 +377,15 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
|
|||||||
xrayConfig.InboundConfigs = append(xrayConfig.InboundConfigs, *inboundConfig)
|
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.
|
// Merge subscription-derived outbounds (if any) into the final outbounds array.
|
||||||
// These are additive: each subscription is placed before or after the template
|
// 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
|
// 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
|
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
|
// PanelEgressInboundTag is the tag of the loopback SOCKS inbound injected into
|
||||||
// the generated config when a panel outbound is configured. The panel's own
|
// the generated config when a panel outbound is configured. The panel's own
|
||||||
// HTTP clients dial through it to egress via the chosen outbound.
|
// 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,18 @@
|
|||||||
"pinnedCertPlaceholder": "base64 or hex SHA-256",
|
"pinnedCertPlaceholder": "base64 or hex SHA-256",
|
||||||
"fetchPin": "Fetch",
|
"fetchPin": "Fetch",
|
||||||
"pinFetched": "Fetched the node's current certificate",
|
"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",
|
||||||
|
"nodeTypeAgent": "Agent",
|
||||||
|
"agentHint": "Agent nodes don't need a panel address. An agent token will be generated on creation for configuring the agent on the node machine.",
|
||||||
|
"agentTokenTitle": "Agent Token",
|
||||||
|
"agentTokenCreated": "Node {{name}} created successfully",
|
||||||
|
"agentTokenHint": "Copy the token below and configure it in agent.json on the node machine. This token is shown only once!",
|
||||||
|
"agentTokenInstructions": "Run the install script on the node machine, or manually create /etc/x-ui/agent.json with the master URL and this token, then start the agent.",
|
||||||
|
"agentTokenCopied": "Token copied",
|
||||||
|
"agentTokenCopyFailed": "Copy failed, please copy manually",
|
||||||
|
"copyToken": "Copy Token"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"defaultTag": "Default",
|
"defaultTag": "Default",
|
||||||
|
|||||||
@@ -1061,7 +1061,18 @@
|
|||||||
"pinnedCertPlaceholder": "base64 或 hex 的 SHA-256",
|
"pinnedCertPlaceholder": "base64 或 hex 的 SHA-256",
|
||||||
"fetchPin": "获取",
|
"fetchPin": "获取",
|
||||||
"pinFetched": "已获取节点当前证书",
|
"pinFetched": "已获取节点当前证书",
|
||||||
"pinFetchFailed": "无法获取证书"
|
"pinFetchFailed": "无法获取证书",
|
||||||
|
"nodeType": "节点类型",
|
||||||
|
"nodeTypeTraditional": "传统面板",
|
||||||
|
"nodeTypeAgent": "Agent",
|
||||||
|
"agentHint": "Agent 节点无需面板地址,创建后会自动生成 agent token,用于在节点机上配置 agent。",
|
||||||
|
"agentTokenTitle": "Agent Token",
|
||||||
|
"agentTokenCreated": "节点 {{name}} 创建成功",
|
||||||
|
"agentTokenHint": "请复制下面的 token,配置到节点机的 agent.json 中。此 token 只显示一次!",
|
||||||
|
"agentTokenInstructions": "在节点机上运行安装脚本,或手动创建 /etc/x-ui/agent.json 并填入 master 地址和此 token,然后启动 agent。",
|
||||||
|
"agentTokenCopied": "Token 已复制",
|
||||||
|
"agentTokenCopyFailed": "复制失败,请手动复制",
|
||||||
|
"copyToken": "复制 Token"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"defaultTag": "默认",
|
"defaultTag": "默认",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"github.com/mhsanaei/3x-ui/v3/internal/crypto/nodetoken"
|
"github.com/mhsanaei/3x-ui/v3/internal/crypto/nodetoken"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/agent"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/sub"
|
"github.com/mhsanaei/3x-ui/v3/internal/sub"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/tunnelmonitor"
|
"github.com/mhsanaei/3x-ui/v3/internal/tunnelmonitor"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
|
"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
|
||||||
@@ -66,6 +67,44 @@ func initNodeTokenCrypto() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// runWebServer initializes and starts the web server for the 3x-ui panel.
|
// 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() {
|
func runWebServer() {
|
||||||
log.Printf("Starting %v %v", config.GetName(), config.GetPanelVersion())
|
log.Printf("Starting %v %v", config.GetName(), config.GetPanelVersion())
|
||||||
|
|
||||||
@@ -644,6 +683,8 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
runWebServer()
|
runWebServer()
|
||||||
|
case "agent":
|
||||||
|
runAgent(os.Args[2:])
|
||||||
case "migrate":
|
case "migrate":
|
||||||
migrateDb()
|
migrateDb()
|
||||||
case "encrypt-tokens":
|
case "encrypt-tokens":
|
||||||
@@ -742,6 +783,7 @@ func commandHelp() string {
|
|||||||
return `
|
return `
|
||||||
Commands:
|
Commands:
|
||||||
run run web panel
|
run run web panel
|
||||||
|
agent run in agent mode (lightweight node, no web panel)
|
||||||
migrate migrate from other/old x-ui
|
migrate migrate from other/old x-ui
|
||||||
migrate-db SQLite <-> .dump (--dump/--restore) or copy into PostgreSQL (--dsn)
|
migrate-db SQLite <-> .dump (--dump/--restore) or copy into PostgreSQL (--dsn)
|
||||||
encrypt-tokens encrypt node bearer tokens with the configured active key
|
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
|
||||||
Reference in New Issue
Block a user