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