diff --git a/internal/sub/links.go b/internal/sub/links.go index e281df7..b6cd175 100644 --- a/internal/sub/links.go +++ b/internal/sub/links.go @@ -38,6 +38,16 @@ func (p *LinkProvider) SubLinksForSubId(host, subId string) ([]string, error) { func (p *LinkProvider) LinksForClient(host string, inbound *model.Inbound, email string) []string { svc := p.build(host) svc.projectThroughFallbackMaster(inbound) + // Managed Host records apply to per-client links exactly as they do + // to subscriptions (getSubs) and export-all (inboundLinks): after the + // fallback projection, a host's address/TLS wins over the plain + // endpoint. Inbounds without hosts keep the legacy output unchanged; + // a client that cannot be resolved falls back to the plain link too. + if eps := svc.hostEndpoints(inbound, "raw"); len(eps) > 0 { + if client, ok := svc.clientForLink(inbound, email); ok { + return splitLinkLines(svc.linkFromHosts(inbound, client, eps)) + } + } return splitLinkLines(svc.GetLink(inbound, email)) } diff --git a/internal/sub/links_host_test.go b/internal/sub/links_host_test.go new file mode 100644 index 0000000..56922b3 --- /dev/null +++ b/internal/sub/links_host_test.go @@ -0,0 +1,117 @@ +package sub + +import ( + "strings" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +// Per-client links (GET /panel/api/clients/links/:email, served by +// LinksForClient) must apply an inbound's managed Host records with the +// same precedence as subscriptions (GetSubs) and export-all +// (inboundLinks): after the fallback projection the host endpoint wins. +// Before this coverage existed the per-client endpoint skipped hosts +// entirely, for traditional and agent inbounds alike. + +const linksHostStream = `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}` + +func TestLinksForClient_AppliesHostRecords(t *testing.T) { + seedSubDB(t) + ib := seedSubInbound(t, "s1", "h", 4433, 1, linksHostStream) + seedHost(t, &model.Host{InboundId: ib.Id, Remark: "CDN", Address: "cdn.example.com", Port: 8443, Security: "tls", Sni: "cdn.sni"}) + + links := NewLinkProvider().LinksForClient("req.example.com", ib, "h@e") + if len(links) != 1 { + t.Fatalf("links = %d, want 1: %v", len(links), links) + } + if !strings.Contains(links[0], "cdn.example.com:8443") || !strings.Contains(links[0], "sni=cdn.sni") { + t.Fatalf("per-client link must carry the host endpoint: %s", links[0]) + } + if strings.Contains(links[0], "203.0.113.5:4433") { + t.Fatalf("per-client link leaked the plain inbound endpoint despite a host record: %s", links[0]) + } +} + +func TestLinksForClient_MultipleHostsOrdered(t *testing.T) { + seedSubDB(t) + ib := seedSubInbound(t, "s1", "m", 4435, 1, linksHostStream) + seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 2, Remark: "B", Address: "b.cdn.com", Port: 8443, Security: "tls"}) + seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "A", Address: "a.cdn.com", Port: 2096, Security: "tls"}) + + links := NewLinkProvider().LinksForClient("req.example.com", ib, "m@e") + if len(links) != 2 { + t.Fatalf("links = %d, want 2 (one per host): %v", len(links), links) + } + if !strings.Contains(links[0], "a.cdn.com:2096") || !strings.Contains(links[1], "b.cdn.com:8443") { + t.Fatalf("host links out of order or wrong: %v", links) + } +} + +func TestLinksForClient_NoHosts_Unchanged(t *testing.T) { + seedSubDB(t) + ib := seedSubInbound(t, "s1", "z", 4436, 1, linksHostStream) + + links := NewLinkProvider().LinksForClient("req.example.com", ib, "z@e") + if len(links) != 1 { + t.Fatalf("links = %d, want 1: %v", len(links), links) + } + if !strings.Contains(links[0], "203.0.113.5:4436") { + t.Fatalf("zero-host per-client link must keep the legacy endpoint: %s", links[0]) + } +} + +// seedAgentInbound wires a node + agent binding (telemetry IPv4) and an +// inbound hosted on that node, mirroring production agent rows. +func seedAgentInbound(t *testing.T, tag string, port int) (*model.Inbound, *model.Node) { + t.Helper() + db := database.GetDB() + node := &model.Node{Name: "agent-" + tag, Scheme: "https", Address: "agent", Port: 2053, Enable: true} + if err := db.Create(node).Error; err != nil { + t.Fatalf("create node: %v", err) + } + ag := &model.AgentNode{TokenHash: "hash-" + tag, Name: node.Name, NodeID: &node.Id, IPv4: "198.51.100.23", LastSeen: 2000} + if err := db.Create(ag).Error; err != nil { + t.Fatalf("create binding: %v", err) + } + ib := seedSubInbound(t, "s1", tag, port, 1, linksHostStream) + if err := db.Model(&model.Inbound{}).Where("id = ?", ib.Id).Update("node_id", node.Id).Error; err != nil { + t.Fatalf("attach inbound to node: %v", err) + } + ib.NodeID = &node.Id + return ib, node +} + +func TestLinksForClient_AgentInbound_HostBeatsTelemetry(t *testing.T) { + seedSubDB(t) + ib, _ := seedAgentInbound(t, "ah", 4437) + seedHost(t, &model.Host{InboundId: ib.Id, Remark: "CDN", Address: "cdn.example.com", Port: 8443, Security: "tls"}) + + links := NewLinkProvider().LinksForClient("req.example.com", ib, "ah@e") + if len(links) != 1 { + t.Fatalf("links = %d, want 1: %v", len(links), links) + } + if !strings.Contains(links[0], "cdn.example.com:8443") { + t.Fatalf("agent per-client link must carry the host endpoint: %s", links[0]) + } + if strings.Contains(links[0], "198.51.100.23") { + t.Fatalf("host record must win over the agent telemetry address: %s", links[0]) + } +} + +func TestLinksForClient_AgentInbound_NoHost_TelemetryPreserved(t *testing.T) { + seedSubDB(t) + ib, _ := seedAgentInbound(t, "an", 4438) + + links := NewLinkProvider().LinksForClient("req.example.com", ib, "an@e") + if len(links) != 1 { + t.Fatalf("links = %d, want 1: %v", len(links), links) + } + if !strings.Contains(links[0], "198.51.100.23:4438") { + t.Fatalf("agent per-client link without hosts must keep the telemetry endpoint: %s", links[0]) + } + if strings.Contains(links[0], "@agent:") { + t.Fatalf("agent per-client link leaks the placeholder address: %s", links[0]) + } +} diff --git a/internal/web/runtime/remote.go b/internal/web/runtime/remote.go index 594af64..0d889c6 100644 --- a/internal/web/runtime/remote.go +++ b/internal/web/runtime/remote.go @@ -151,11 +151,26 @@ func (r *Remote) httpClient() (*http.Client, error) { return r.client, r.clientErr } +// isAgentPlaceholderAddress reports whether addr is the placeholder the +// node form used to store for agent nodes ("agent"). Agent nodes dial out +// to the master and expose no panel endpoint; the write path no longer +// stores the placeholder (see sanitizeAgentEndpoint in the node service), +// but rows written earlier still carry it, and "agent" is a syntactically +// valid hostname — without this guard baseURL would splice it into a +// dialable URL (https://agent:2053/...) and do() would fire real requests +// at whatever "agent" happens to resolve to. +func isAgentPlaceholderAddress(addr string) bool { + return strings.EqualFold(addr, "agent") +} + func (r *Remote) baseURL() (string, error) { addr, err := netsafe.NormalizeHost(r.node.Address) if err != nil { return "", err } + if isAgentPlaceholderAddress(addr) { + return "", errors.New("node has no panel endpoint (agent-managed nodes cannot be dialed)") + } scheme := r.node.Scheme if scheme != "http" && scheme != "https" { scheme = "https" diff --git a/internal/web/runtime/remote_test.go b/internal/web/runtime/remote_test.go index a970730..4a5c2f6 100644 --- a/internal/web/runtime/remote_test.go +++ b/internal/web/runtime/remote_test.go @@ -273,6 +273,33 @@ func TestRemoteBaseURL(t *testing.T) { } } +// Legacy agent-node rows store the node form's placeholder pair +// (address "agent", port 2053). "agent" is a syntactically valid +// hostname, so without a guard baseURL would splice it into a dialable +// URL — https://agent:2053/... — and do() would fire real requests at +// whatever "agent" resolves to. The placeholder must be refused with a +// clear endpoint error instead, in any casing. +func TestRemoteBaseURL_RejectsAgentPlaceholder(t *testing.T) { + for _, addr := range []string{"agent", "Agent", "AGENT"} { + r := NewRemote(&model.Node{Address: addr, Port: 2053, Scheme: "https"}, nil) + got, err := r.baseURL() + if err == nil || !strings.Contains(err.Error(), "no panel endpoint") { + t.Fatalf("baseURL(%q) = %q, %v; want a panel-endpoint error", addr, got, err) + } + } +} + +// The latent trigger: a placeholder row that also carries a panel API +// token passes do()'s credential gate and reaches baseURL. The request +// must die there — never on the network. +func TestRemoteDo_AgentPlaceholderNeverDials(t *testing.T) { + r := NewRemote(&model.Node{Address: "agent", Port: 2053, Scheme: "https", ApiToken: "tok"}, nil) + _, err := r.do(context.Background(), http.MethodGet, "/probe", nil) + if err == nil || !strings.Contains(err.Error(), "no panel endpoint") { + t.Fatalf("do() error = %v, want a panel-endpoint error", err) + } +} + func TestIsNonEmptySlice(t *testing.T) { cases := []struct { name string diff --git a/internal/web/service/node.go b/internal/web/service/node.go index 5320758..973a23a 100644 --- a/internal/web/service/node.go +++ b/internal/web/service/node.go @@ -435,18 +435,32 @@ func normalizeBasePath(p string) string { } func (s *NodeService) normalize(n *model.Node) error { + return s.normalizeMode(n, false) +} + +// normalizeMode validates a node row before it is stored. agentEndpoint +// marks an agent-node write: agent nodes have no panel endpoint to dial, +// so an unset address/port pair (the form placeholder, sanitized away by +// sanitizeAgentEndpoint) is legitimate and skips the host/port checks. +// Any value that IS present is validated exactly like a traditional +// node's. +func (s *NodeService) normalizeMode(n *model.Node, agentEndpoint bool) error { n.Name = strings.TrimSpace(n.Name) n.ApiToken = strings.TrimSpace(n.ApiToken) if n.Name == "" { return common.NewError("node name is required") } - addr, err := netsafe.NormalizeHost(n.Address) - if err != nil { - return common.NewError(err.Error()) + if !agentEndpoint || n.Address != "" { + addr, err := netsafe.NormalizeHost(n.Address) + if err != nil { + return common.NewError(err.Error()) + } + n.Address = addr } - n.Address = addr - if n.Port <= 0 || n.Port > 65535 { - return common.NewError("node port must be 1-65535") + if !agentEndpoint || n.Port != 0 { + if n.Port <= 0 || n.Port > 65535 { + return common.NewError("node port must be 1-65535") + } } if n.Scheme != "http" && n.Scheme != "https" { n.Scheme = "https" @@ -487,7 +501,11 @@ func (s *NodeService) normalize(n *model.Node) error { } func (s *NodeService) Create(n *model.Node) error { - if err := s.normalize(n); err != nil { + return s.create(n, false) +} + +func (s *NodeService) create(n *model.Node, agentEndpoint bool) error { + if err := s.normalizeMode(n, agentEndpoint); err != nil { return err } db := database.GetDB() @@ -520,13 +538,40 @@ func (s *NodeService) CreateFromRequest(req *NodeMutationRequest) (*NodeView, er if err := req.validateCredentials(true); err != nil { return nil, err } + isAgent := req.NodeType == "agent" + if isAgent { + // A brand-new agent node has no binding yet, so no resolved + // address can be known: the placeholder sanitizes to empty and + // is filled in by later writes once the agent reports. + sanitizeAgentEndpoint(req, "") + } n := req.toNode() - if err := s.Create(n); err != nil { + if err := s.create(n, isAgent); err != nil { return nil, err } return toNodeView(n), nil } +// resolvedAgentAddress returns the externally usable address of the agent +// bound to a node (latest binding wins, via ResolveAgentAddresses), or "" +// when the node has no bound agent or the agent never reported one. The +// stored row of an agent node carries this value (or empty) instead of +// the create form's placeholder — see sanitizeAgentEndpoint. +func (s *NodeService) resolvedAgentAddress(nodeID int) string { + var ag model.AgentNode + if err := database.GetDB(). + Where("node_id = ?", nodeID). + Order("last_seen DESC"). + First(&ag).Error; err != nil { + return "" + } + v4, v6 := ResolveAgentAddresses(&ag) + if v4 != "" { + return v4 + } + return v6 +} + func (s *NodeService) Update(id int, in *model.Node) error { if err := s.normalize(in); err != nil { return err @@ -583,8 +628,12 @@ func (s *NodeService) UpdateFromRequest(id int, req *NodeMutationRequest) error if err := req.validateCredentials(false); err != nil { return err } + isAgent := req.NodeType == "agent" || s.IsAgentManaged(id) + if isAgent { + sanitizeAgentEndpoint(req, s.resolvedAgentAddress(id)) + } in := req.toNode() - if err := s.normalize(in); err != nil { + if err := s.normalizeMode(in, isAgent); err != nil { return err } inboundTagsJSON, err := json.Marshal(in.InboundTags) @@ -608,7 +657,6 @@ func (s *NodeService) UpdateFromRequest(id int, req *NodeMutationRequest) error } // Agent nodes authenticate with their agent token, never a panel API // token — the requirement below must not make them uneditable. - isAgent := req.NodeType == "agent" || s.IsAgentManaged(id) if !isAgent && apiToken == "" && in.Enable && in.TlsVerifyMode != "mtls" { return common.NewError("apiToken is required unless mtls is enabled") } diff --git a/internal/web/service/node_agent_endpoint_test.go b/internal/web/service/node_agent_endpoint_test.go new file mode 100644 index 0000000..4fdc34a --- /dev/null +++ b/internal/web/service/node_agent_endpoint_test.go @@ -0,0 +1,152 @@ +package service + +import ( + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +// Agent nodes have no panel endpoint: the node form submits the +// placeholder pair address="agent", port=2053 for them. The write paths +// must never store that pair — an agent node's row carries its resolved +// address when one is known, or empty when it is not — while traditional +// nodes keep the strict endpoint validation they always had. + +func strPtr(s string) *string { return &s } + +func TestCreateFromRequest_AgentPlaceholderNotStored(t *testing.T) { + setupConflictDB(t) + svc := &NodeService{} + + view, err := svc.CreateFromRequest(&NodeMutationRequest{ + Name: "agent-new", Enable: true, Scheme: "https", + Address: "agent", Port: 2053, NodeType: "agent", + }) + if err != nil { + t.Fatalf("CreateFromRequest(agent): %v", err) + } + if view.Address == "agent" || view.Port == 2053 { + t.Fatalf("create view still carries the placeholder: %+v", view) + } + stored, err := svc.GetById(view.Id) + if err != nil { + t.Fatalf("GetById: %v", err) + } + // A brand-new agent node has no binding, hence no resolved address: + // the honest stored value is empty, port 0. + if stored.Address != "" || stored.Port != 0 { + t.Fatalf("stored agent endpoint = %q:%d, want empty:0", stored.Address, stored.Port) + } +} + +func TestCreateFromRequest_TraditionalKeepsEndpoint(t *testing.T) { + setupConflictDB(t) + svc := &NodeService{} + + view, err := svc.CreateFromRequest(&NodeMutationRequest{ + Name: "trad-new", Enable: true, Scheme: "https", + Address: "198.51.100.10", Port: 8443, ApiToken: strPtr("tok-1"), + }) + if err != nil { + t.Fatalf("CreateFromRequest(traditional): %v", err) + } + stored, err := svc.GetById(view.Id) + if err != nil { + t.Fatalf("GetById: %v", err) + } + if stored.Address != "198.51.100.10" || stored.Port != 8443 { + t.Fatalf("stored traditional endpoint = %q:%d, want 198.51.100.10:8443", stored.Address, stored.Port) + } +} + +func TestCreateFromRequest_TraditionalEmptyAddressStillRejected(t *testing.T) { + setupConflictDB(t) + svc := &NodeService{} + + if _, err := svc.CreateFromRequest(&NodeMutationRequest{ + Name: "trad-bad", Enable: true, Scheme: "https", ApiToken: strPtr("tok-2"), + }); err == nil { + t.Fatal("traditional create with an empty address must still fail validation") + } +} + +// seedAgentNodeRow stores a legacy placeholder row plus its agent +// binding; ipv4 == "" models an agent that never reported an address. +func seedAgentNodeRow(t *testing.T, name, ipv4 string) *model.Node { + t.Helper() + db := database.GetDB() + node := &model.Node{Name: name, Scheme: "https", Address: "agent", Port: 2053, Enable: true} + if err := db.Create(node).Error; err != nil { + t.Fatalf("create node: %v", err) + } + ag := &model.AgentNode{TokenHash: "hash-" + name, Name: name, NodeID: &node.Id, IPv4: ipv4, LastSeen: 100} + if err := db.Create(ag).Error; err != nil { + t.Fatalf("create binding: %v", err) + } + return node +} + +func TestUpdateFromRequest_AgentPlaceholderReplacedByResolved(t *testing.T) { + setupConflictDB(t) + node := seedAgentNodeRow(t, "agent-upd", "203.0.113.9") + svc := &NodeService{} + + // Exactly what the edit form submits for an agent node today. + err := svc.UpdateFromRequest(node.Id, &NodeMutationRequest{ + Name: "agent-upd", Enable: true, Scheme: "https", + Address: "agent", Port: 2053, NodeType: "agent", + }) + if err != nil { + t.Fatalf("UpdateFromRequest(agent): %v", err) + } + stored, err := svc.GetById(node.Id) + if err != nil { + t.Fatalf("GetById: %v", err) + } + if stored.Address != "203.0.113.9" || stored.Port != 0 { + t.Fatalf("stored agent endpoint = %q:%d, want resolved 203.0.113.9:0", stored.Address, stored.Port) + } +} + +func TestUpdateFromRequest_AgentNoReport_SanitizesToEmpty(t *testing.T) { + setupConflictDB(t) + node := seedAgentNodeRow(t, "agent-silent", "") + svc := &NodeService{} + + err := svc.UpdateFromRequest(node.Id, &NodeMutationRequest{ + Name: "agent-silent", Enable: true, Scheme: "https", + Address: "agent", Port: 2053, NodeType: "agent", + }) + if err != nil { + t.Fatalf("UpdateFromRequest(agent): %v", err) + } + stored, err := svc.GetById(node.Id) + if err != nil { + t.Fatalf("GetById: %v", err) + } + if stored.Address != "" || stored.Port != 0 { + t.Fatalf("stored agent endpoint = %q:%d, want empty:0 (no placeholder, no invented address)", stored.Address, stored.Port) + } +} + +func TestUpdateFromRequest_AgentRealAddressKeptVerbatim(t *testing.T) { + setupConflictDB(t) + node := seedAgentNodeRow(t, "agent-real", "203.0.113.9") + svc := &NodeService{} + + err := svc.UpdateFromRequest(node.Id, &NodeMutationRequest{ + Name: "agent-real", Enable: true, Scheme: "https", + Address: "edge.example.com", Port: 443, NodeType: "agent", + }) + if err != nil { + t.Fatalf("UpdateFromRequest(agent, real address): %v", err) + } + stored, err := svc.GetById(node.Id) + if err != nil { + t.Fatalf("GetById: %v", err) + } + if stored.Address != "edge.example.com" || stored.Port != 443 { + t.Fatalf("stored agent endpoint = %q:%d, want submitted edge.example.com:443", stored.Address, stored.Port) + } +} diff --git a/internal/web/service/node_contract.go b/internal/web/service/node_contract.go index ab58d60..d6a7504 100644 --- a/internal/web/service/node_contract.go +++ b/internal/web/service/node_contract.go @@ -313,6 +313,31 @@ func (r *NodeMutationRequest) validateCredentials(create bool) error { return nil } +// isAgentPlaceholderAddress reports whether addr is the placeholder the +// node form submits for agent nodes ("agent": they dial out to the master +// and have no panel endpoint to store). Keep in sync with the guard in +// internal/web/runtime's baseURL, which refuses to dial the placeholder +// for rows written before this sanitization existed. +func isAgentPlaceholderAddress(addr string) bool { + return strings.EqualFold(strings.TrimSpace(addr), "agent") +} + +// sanitizeAgentEndpoint rewrites an agent-node write's endpoint fields so +// the form placeholder never reaches the nodes table: a placeholder (or +// empty) address is replaced by the node's currently resolved address — +// "" when none is known yet (a new node, or an agent that never +// reported) — and the port is dropped to 0, since an agent node has no +// panel port either. A real address submitted for an agent node is kept +// verbatim, port included. +func sanitizeAgentEndpoint(req *NodeMutationRequest, resolved string) { + addr := strings.TrimSpace(req.Address) + if addr != "" && !isAgentPlaceholderAddress(addr) { + return + } + req.Address = resolved + req.Port = 0 +} + func (r *NodeMutationRequest) toNode() *model.Node { n := &model.Node{ Id: r.Id,