fix three V1.0.1 leftover tails
1. per-client links (LinkProvider.LinksForClient) now apply the inbound's managed Host records with the same precedence as subscriptions and export-all (host endpoints after fallback projection); zero-host inbounds keep the legacy output. 2. agent node writes no longer store the form placeholder address='agent', port=2053: CreateFromRequest/UpdateFromRequest sanitize it to the node's resolved agent address (update) or empty (create / unreported), port 0; normalize gains a lenient endpoint mode for agent writes only. 3. runtime Remote.baseURL refuses the legacy 'agent' placeholder with a clear no-panel-endpoint error instead of splicing it into a dialable https://agent:2053 URL.
This commit is contained in:
@@ -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 {
|
func (p *LinkProvider) LinksForClient(host string, inbound *model.Inbound, email string) []string {
|
||||||
svc := p.build(host)
|
svc := p.build(host)
|
||||||
svc.projectThroughFallbackMaster(inbound)
|
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))
|
return splitLinkLines(svc.GetLink(inbound, email))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -151,11 +151,26 @@ func (r *Remote) httpClient() (*http.Client, error) {
|
|||||||
return r.client, r.clientErr
|
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) {
|
func (r *Remote) baseURL() (string, error) {
|
||||||
addr, err := netsafe.NormalizeHost(r.node.Address)
|
addr, err := netsafe.NormalizeHost(r.node.Address)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
if isAgentPlaceholderAddress(addr) {
|
||||||
|
return "", errors.New("node has no panel endpoint (agent-managed nodes cannot be dialed)")
|
||||||
|
}
|
||||||
scheme := r.node.Scheme
|
scheme := r.node.Scheme
|
||||||
if scheme != "http" && scheme != "https" {
|
if scheme != "http" && scheme != "https" {
|
||||||
scheme = "https"
|
scheme = "https"
|
||||||
|
|||||||
@@ -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) {
|
func TestIsNonEmptySlice(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -435,18 +435,32 @@ func normalizeBasePath(p string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *NodeService) normalize(n *model.Node) error {
|
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.Name = strings.TrimSpace(n.Name)
|
||||||
n.ApiToken = strings.TrimSpace(n.ApiToken)
|
n.ApiToken = strings.TrimSpace(n.ApiToken)
|
||||||
if n.Name == "" {
|
if n.Name == "" {
|
||||||
return common.NewError("node name is required")
|
return common.NewError("node name is required")
|
||||||
}
|
}
|
||||||
addr, err := netsafe.NormalizeHost(n.Address)
|
if !agentEndpoint || n.Address != "" {
|
||||||
if err != nil {
|
addr, err := netsafe.NormalizeHost(n.Address)
|
||||||
return common.NewError(err.Error())
|
if err != nil {
|
||||||
|
return common.NewError(err.Error())
|
||||||
|
}
|
||||||
|
n.Address = addr
|
||||||
}
|
}
|
||||||
n.Address = addr
|
if !agentEndpoint || n.Port != 0 {
|
||||||
if n.Port <= 0 || n.Port > 65535 {
|
if n.Port <= 0 || n.Port > 65535 {
|
||||||
return common.NewError("node port must be 1-65535")
|
return common.NewError("node port must be 1-65535")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if n.Scheme != "http" && n.Scheme != "https" {
|
if n.Scheme != "http" && n.Scheme != "https" {
|
||||||
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 {
|
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
|
return err
|
||||||
}
|
}
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
@@ -520,13 +538,40 @@ func (s *NodeService) CreateFromRequest(req *NodeMutationRequest) (*NodeView, er
|
|||||||
if err := req.validateCredentials(true); err != nil {
|
if err := req.validateCredentials(true); err != nil {
|
||||||
return nil, err
|
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()
|
n := req.toNode()
|
||||||
if err := s.Create(n); err != nil {
|
if err := s.create(n, isAgent); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return toNodeView(n), nil
|
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 {
|
func (s *NodeService) Update(id int, in *model.Node) error {
|
||||||
if err := s.normalize(in); err != nil {
|
if err := s.normalize(in); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -583,8 +628,12 @@ func (s *NodeService) UpdateFromRequest(id int, req *NodeMutationRequest) error
|
|||||||
if err := req.validateCredentials(false); err != nil {
|
if err := req.validateCredentials(false); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
isAgent := req.NodeType == "agent" || s.IsAgentManaged(id)
|
||||||
|
if isAgent {
|
||||||
|
sanitizeAgentEndpoint(req, s.resolvedAgentAddress(id))
|
||||||
|
}
|
||||||
in := req.toNode()
|
in := req.toNode()
|
||||||
if err := s.normalize(in); err != nil {
|
if err := s.normalizeMode(in, isAgent); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
inboundTagsJSON, err := json.Marshal(in.InboundTags)
|
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
|
// Agent nodes authenticate with their agent token, never a panel API
|
||||||
// token — the requirement below must not make them uneditable.
|
// token — the requirement below must not make them uneditable.
|
||||||
isAgent := req.NodeType == "agent" || s.IsAgentManaged(id)
|
|
||||||
if !isAgent && apiToken == "" && in.Enable && in.TlsVerifyMode != "mtls" {
|
if !isAgent && apiToken == "" && in.Enable && in.TlsVerifyMode != "mtls" {
|
||||||
return common.NewError("apiToken is required unless mtls is enabled")
|
return common.NewError("apiToken is required unless mtls is enabled")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -313,6 +313,31 @@ func (r *NodeMutationRequest) validateCredentials(create bool) error {
|
|||||||
return nil
|
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 {
|
func (r *NodeMutationRequest) toNode() *model.Node {
|
||||||
n := &model.Node{
|
n := &model.Node{
|
||||||
Id: r.Id,
|
Id: r.Id,
|
||||||
|
|||||||
Reference in New Issue
Block a user