From 0ba1ec05966a14fddaf1521a3921405395a6b5f0 Mon Sep 17 00:00:00 2001 From: Muse Date: Mon, 19 Oct 2026 12:29:38 +0000 Subject: [PATCH] =?UTF-8?q?3x-ui=20agent:=20=E8=BD=BB=E9=87=8F=E8=8A=82?= =?UTF-8?q?=E7=82=B9=20agent=20=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/AGENT.md | 125 ++++++++ install-agent.sh | 75 +++++ install-master.sh | 52 +++ internal/agent/agent.go | 407 ++++++++++++++++++++++++ internal/database/db.go | 1 + internal/database/migrate_data.go | 1 + internal/database/model/agent_node.go | 25 ++ internal/web/controller/agent.go | 384 ++++++++++++++++++++++ internal/web/controller/api.go | 16 + internal/web/controller/node.go | 20 +- internal/web/job/node_heartbeat_job.go | 9 + internal/web/service/node_contract.go | 7 +- internal/web/service/xray.go | 130 +++++++- internal/web/service/xray_agent_test.go | 98 ++++++ internal/web/translation/en-US.json | 15 +- internal/web/translation/zh-CN.json | 15 +- main.go | 42 +++ openrc/x-ui-agent | 18 ++ systemd/x-ui-agent.service | 14 + 19 files changed, 1444 insertions(+), 10 deletions(-) create mode 100644 docs/AGENT.md create mode 100755 install-agent.sh create mode 100755 install-master.sh create mode 100644 internal/agent/agent.go create mode 100644 internal/database/model/agent_node.go create mode 100644 internal/web/controller/agent.go create mode 100644 internal/web/service/xray_agent_test.go create mode 100644 openrc/x-ui-agent create mode 100644 systemd/x-ui-agent.service diff --git a/docs/AGENT.md b/docs/AGENT.md new file mode 100644 index 0000000..87dab6f --- /dev/null +++ b/docs/AGENT.md @@ -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 " \ + -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 " \ + 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: + +```bash +# 查看当前 egress(未设置时返回空数组,走模板默认 direct/blocked) +curl -sk -H "Authorization: Bearer " \ + https://主控地址:2053/panel/api/agent/egress/ + +# 写入出站+路由规则(完整数组,直接替换模板默认) +curl -sk -H "Authorization: Bearer " \ + -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/ +``` + +- 下发配置里的 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 " -X DELETE https://主控地址:2053/panel/api/agent/tokens/`, + 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://` 内网地址 | diff --git a/install-agent.sh b/install-agent.sh new file mode 100755 index 0000000..8f938ad --- /dev/null +++ b/install-agent.sh @@ -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 </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 diff --git a/install-master.sh b/install-master.sh new file mode 100755 index 0000000..8146e13 --- /dev/null +++ b/install-master.sh @@ -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 diff --git a/internal/agent/agent.go b/internal/agent/agent.go new file mode 100644 index 0000000..bddd6ab --- /dev/null +++ b/internal/agent/agent.go @@ -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 +} diff --git a/internal/database/db.go b/internal/database/db.go index 5dec871..801c4ae 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -85,6 +85,7 @@ func allModels() []any { &model.ClientGlobalTraffic{}, &model.OutboundSubscription{}, &model.SubBalancer{}, + &model.AgentNode{}, } } diff --git a/internal/database/migrate_data.go b/internal/database/migrate_data.go index 3a423c9..a52e6f1 100644 --- a/internal/database/migrate_data.go +++ b/internal/database/migrate_data.go @@ -58,6 +58,7 @@ func migrationModels() []any { &model.ClientGlobalTraffic{}, &model.OutboundSubscription{}, &model.SubBalancer{}, + &model.AgentNode{}, } } diff --git a/internal/database/model/agent_node.go b/internal/database/model/agent_node.go new file mode 100644 index 0000000..dbb1024 --- /dev/null +++ b/internal/database/model/agent_node.go @@ -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"` +} diff --git a/internal/web/controller/agent.go b/internal/web/controller/agent.go new file mode 100644 index 0000000..61dde3a --- /dev/null +++ b/internal/web/controller/agent.go @@ -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 +} diff --git a/internal/web/controller/api.go b/internal/web/controller/api.go index 483ed1d..490047d 100644 --- a/internal/web/controller/api.go +++ b/internal/web/controller/api.go @@ -19,6 +19,7 @@ type APIController struct { inboundController *InboundController serverController *ServerController nodeController *NodeController + agentController *AgentController hostController *HostController settingController *SettingController xraySettingController *XraySettingController @@ -191,6 +192,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) diff --git a/internal/web/controller/node.go b/internal/web/controller/node.go index beaaec6..c69aac5 100644 --- a/internal/web/controller/node.go +++ b/internal/web/controller/node.go @@ -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) } diff --git a/internal/web/job/node_heartbeat_job.go b/internal/web/job/node_heartbeat_job.go index bb37d7c..3247d07 100644 --- a/internal/web/job/node_heartbeat_job.go +++ b/internal/web/job/node_heartbeat_job.go @@ -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 diff --git a/internal/web/service/node_contract.go b/internal/web/service/node_contract.go index b21e615..5526c0a 100644 --- a/internal/web/service/node_contract.go +++ b/internal/web/service/node_contract.go @@ -130,6 +130,10 @@ 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 { @@ -152,7 +156,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") } } diff --git a/internal/web/service/xray.go b/internal/web/service/xray.go index 6e11543..30742a3 100644 --- a/internal/web/service/xray.go +++ b/internal/web/service/xray.go @@ -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. diff --git a/internal/web/service/xray_agent_test.go b/internal/web/service/xray_agent_test.go new file mode 100644 index 0000000..a4dd069 --- /dev/null +++ b/internal/web/service/xray_agent_test.go @@ -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") + } +} diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index b7a37c4..27a6cbf 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -1179,7 +1179,18 @@ "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", + "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": { "defaultTag": "Default", @@ -2249,4 +2260,4 @@ "statusDown": "DOWN", "statusUp": "UP" } -} +} \ No newline at end of file diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 838c30f..983e326 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -1061,7 +1061,18 @@ "pinnedCertPlaceholder": "base64 或 hex 的 SHA-256", "fetchPin": "获取", "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": { "defaultTag": "默认", @@ -2249,4 +2260,4 @@ "statusDown": "断开", "statusUp": "恢复" } -} +} \ No newline at end of file diff --git a/main.go b/main.go index 669a3c4..816b9c3 100644 --- a/main.go +++ b/main.go @@ -18,6 +18,7 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/crypto/nodetoken" "github.com/mhsanaei/3x-ui/v3/internal/database" "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/tunnelmonitor" "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. +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()) @@ -644,6 +683,8 @@ func main() { return } runWebServer() + case "agent": + runAgent(os.Args[2:]) case "migrate": migrateDb() case "encrypt-tokens": @@ -742,6 +783,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 diff --git a/openrc/x-ui-agent b/openrc/x-ui-agent new file mode 100644 index 0000000..bc60d30 --- /dev/null +++ b/openrc/x-ui-agent @@ -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 +} diff --git a/systemd/x-ui-agent.service b/systemd/x-ui-agent.service new file mode 100644 index 0000000..7488d35 --- /dev/null +++ b/systemd/x-ui-agent.service @@ -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