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:
Muse
2026-10-19 12:29:38 +00:00
parent f727d04f65
commit 0ba1ec0596
19 changed files with 1444 additions and 10 deletions
+384
View File
@@ -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
}
+16
View File
@@ -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)
+19 -1
View File
@@ -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)
}
+9
View File
@@ -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
+6 -1
View File
@@ -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")
}
}
+126 -4
View File
@@ -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.
+98
View File
@@ -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")
}
}
+13 -2
View File
@@ -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"
}
}
}
+13 -2
View File
@@ -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": "恢复"
}
}
}