diff --git a/internal/database/model/model.go b/internal/database/model/model.go index 0b5604298..78158b041 100644 --- a/internal/database/model/model.go +++ b/internal/database/model/model.go @@ -502,7 +502,7 @@ type Node struct { Address string `json:"address" form:"address" validate:"required" example:"node1.example.com"` Port int `json:"port" form:"port" validate:"gte=1,lte=65535" example:"2053"` BasePath string `json:"basePath" form:"basePath" example:"/"` - ApiToken string `json:"apiToken" form:"apiToken" validate:"required_unless=TlsVerifyMode mtls" example:"abcdef0123456789"` + ApiToken string `json:"-" form:"-" gorm:"column:api_token" validate:"required_unless=TlsVerifyMode mtls" example:"abcdef0123456789"` Enable bool `json:"enable" form:"enable" gorm:"default:true" example:"true"` AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"` TlsVerifyMode string `json:"tlsVerifyMode" form:"tlsVerifyMode" gorm:"column:tls_verify_mode;default:verify" validate:"omitempty,oneof=verify skip pin mtls"` diff --git a/internal/web/controller/node.go b/internal/web/controller/node.go index a19ea6299..75ad56e6c 100644 --- a/internal/web/controller/node.go +++ b/internal/web/controller/node.go @@ -8,7 +8,6 @@ import ( "strconv" "time" - "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/middleware" "github.com/mhsanaei/3x-ui/v3/internal/web/service" @@ -78,7 +77,7 @@ func (a *NodeController) setMtlsTrustCA(c *gin.Context) { } func (a *NodeController) list(c *gin.Context) { - nodes, err := a.nodeService.GetNodeTree() + nodes, err := a.nodeService.GetNodeTreeView() if err != nil { jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.list"), err) return @@ -92,7 +91,7 @@ func (a *NodeController) get(c *gin.Context) { jsonMsg(c, I18nWeb(c, "get"), err) return } - n, err := a.nodeService.GetById(id) + n, err := a.nodeService.GetViewById(id) if err != nil { jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err) return @@ -116,27 +115,32 @@ func (a *NodeController) webCert(c *gin.Context) { jsonObj(c, files, nil) } -func (a *NodeController) ensureReachable(c *gin.Context, n *model.Node) error { +func (a *NodeController) ensureReachable(c *gin.Context, n *service.NodeMutationRequest, id int) error { + runtimeNode, err := a.nodeService.RuntimeNodeFromRequest(id, n) + if err != nil { + return err + } ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second) defer cancel() - if _, err := a.nodeService.Probe(ctx, n); err != nil { + if _, err := a.nodeService.Probe(ctx, runtimeNode); err != nil { return errors.New(service.FriendlyProbeError(err.Error())) } return nil } func (a *NodeController) add(c *gin.Context) { - n, ok := middleware.BindAndValidate[model.Node](c) + n, ok := middleware.BindAndValidate[service.NodeMutationRequest](c) if !ok { return } if n.OutboundTag == "" { - if err := a.ensureReachable(c, n); err != nil { + if err := a.ensureReachable(c, n, 0); err != nil { jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err) return } } - if err := a.nodeService.Create(n); err != nil { + view, err := a.nodeService.CreateFromRequest(n) + if err != nil { jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err) return } @@ -144,12 +148,12 @@ func (a *NodeController) add(c *gin.Context) { if err := a.xrayService.RestartXray(false); err != nil { logger.Warning("apply node outbound bridge failed:", err) } - if err := a.ensureReachable(c, n); err != nil { + if err := a.ensureReachable(c, n, view.Id); err != nil { jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err) return } } - jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.add"), n, nil) + jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.add"), view, nil) } func (a *NodeController) update(c *gin.Context) { @@ -158,7 +162,7 @@ func (a *NodeController) update(c *gin.Context) { jsonMsg(c, I18nWeb(c, "get"), err) return } - n, ok := middleware.BindAndValidate[model.Node](c) + n, ok := middleware.BindAndValidate[service.NodeMutationRequest](c) if !ok { return } @@ -167,13 +171,13 @@ func (a *NodeController) update(c *gin.Context) { jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err) return } - if n.OutboundTag == "" && old.OutboundTag == "" { - if err := a.ensureReachable(c, n); err != nil { + if n.OutboundTag == "" && old.OutboundTag == "" && !(n.ClearApiToken && !n.Enable) { + if err := a.ensureReachable(c, n, id); err != nil { jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err) return } } - if err := a.nodeService.Update(id, n); err != nil { + if err := a.nodeService.UpdateFromRequest(id, n); err != nil { jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err) return } @@ -181,7 +185,7 @@ func (a *NodeController) update(c *gin.Context) { if err := a.xrayService.RestartXray(false); err != nil { logger.Warning("apply node outbound bridge change failed:", err) } - if err := a.ensureReachable(c, n); err != nil { + if err := a.ensureReachable(c, n, id); err != nil { jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err) return } @@ -233,58 +237,57 @@ func (a *NodeController) setEnable(c *gin.Context) { } func (a *NodeController) inbounds(c *gin.Context) { - n := &model.Node{} - if err := c.ShouldBind(n); err != nil { + n, ok := middleware.BindAndValidate[service.NodeMutationRequest](c) + if !ok { + return + } + runtimeNode, err := a.nodeService.RuntimeNodeFromRequest(n.Id, n) + if err != nil { jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err) return } ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() - options, err := a.nodeService.GetRemoteInboundOptions(ctx, n) + options, err := a.nodeService.GetRemoteInboundOptions(ctx, runtimeNode) jsonObj(c, options, err) } func (a *NodeController) test(c *gin.Context) { - n := &model.Node{} - if err := c.ShouldBind(n); err != nil { - jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err) + n, ok := middleware.BindAndValidate[service.NodeMutationRequest](c) + if !ok { return } - if n.Scheme == "" { - n.Scheme = "https" - } - if n.BasePath == "" { - n.BasePath = "/" + runtimeNode, err := a.nodeService.RuntimeNodeFromRequest(n.Id, n) + if err != nil { + jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err) + return } ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second) defer cancel() var patch service.HeartbeatPatch - var err error - if n.OutboundTag != "" { - patch, err = a.nodeService.ProbeWithOutbound(ctx, n, n.OutboundTag) + if runtimeNode.OutboundTag != "" { + patch, err = a.nodeService.ProbeWithOutbound(ctx, runtimeNode, runtimeNode.OutboundTag) } else { - patch, err = a.nodeService.Probe(ctx, n) + patch, err = a.nodeService.Probe(ctx, runtimeNode) } jsonObj(c, patch.ToUI(err == nil), nil) } func (a *NodeController) certFingerprint(c *gin.Context) { - n := &model.Node{} - if err := c.ShouldBind(n); err != nil { - jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err) + n, ok := middleware.BindAndValidate[service.NodeMutationRequest](c) + if !ok { return } - if n.Scheme == "" { - n.Scheme = "https" - } - if n.BasePath == "" { - n.BasePath = "/" + runtimeNode, err := a.nodeService.NodeFromRequestForCertificate(n) + if err != nil { + jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err) + return } ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second) defer cancel() - fp, err := a.nodeService.FetchCertFingerprint(ctx, n) + fp, err := a.nodeService.FetchCertFingerprint(ctx, runtimeNode) if err != nil { jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err) return diff --git a/internal/web/controller/node_credentials_writeonly_test.go b/internal/web/controller/node_credentials_writeonly_test.go new file mode 100644 index 000000000..af990e82c --- /dev/null +++ b/internal/web/controller/node_credentials_writeonly_test.go @@ -0,0 +1,124 @@ +package controller + +import ( + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/web/locale" +) + +func newNodeCredentialTestEngine(t *testing.T) *gin.Engine { + t.Helper() + gin.SetMode(gin.TestMode) + dbDir := t.TempDir() + t.Setenv("XUI_DB_FOLDER", dbDir) + if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil { + t.Fatalf("InitDB: %v", err) + } + t.Cleanup(func() { _ = database.CloseDB() }) + + engine := gin.New() + engine.Use(func(c *gin.Context) { + c.Set("I18n", func(_ locale.I18nType, key string, _ ...string) string { return key }) + c.Next() + }) + NewNodeController(engine.Group("/panel/api/nodes")) + return engine +} + +func TestNodeControllerResponsesDoNotLeakApiToken(t *testing.T) { + engine := newNodeCredentialTestEngine(t) + if err := database.GetDB().Create(&model.Node{ + Name: "stored-node", + Scheme: "https", + Address: "example.com", + Port: 2053, + BasePath: "/", + ApiToken: "stored-secret-token", + Enable: true, + }).Error; err != nil { + t.Fatalf("seed node: %v", err) + } + + for _, path := range []string{"/panel/api/nodes/list", "/panel/api/nodes/get/1"} { + w := httptest.NewRecorder() + engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) + if w.Code != http.StatusOK { + t.Fatalf("%s status = %d body=%s", path, w.Code, w.Body.String()) + } + body := w.Body.String() + if strings.Contains(body, "stored-secret-token") || strings.Contains(body, "apiToken") { + t.Fatalf("%s leaked api token: %s", path, body) + } + if !strings.Contains(body, `"hasApiToken":true`) { + t.Fatalf("%s did not expose credential presence: %s", path, body) + } + } +} + +func TestNodeControllerAddAcceptsTokenButReturnsView(t *testing.T) { + engine := newNodeCredentialTestEngine(t) + + remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/panel/api/server/status" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer input-secret-token" { + t.Fatalf("Authorization = %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true,"obj":{"cpu":1,"mem":{"current":1,"total":2},"xray":{"version":"1","state":"running"},"panelVersion":"v3.4.1","panelGuid":"guid","uptime":7,"netIO":{"up":3,"down":4}}}`)) + })) + defer remote.Close() + host, portString, err := net.SplitHostPort(strings.TrimPrefix(remote.URL, "http://")) + if err != nil { + t.Fatalf("split remote addr: %v", err) + } + port, err := strconv.Atoi(portString) + if err != nil { + t.Fatalf("parse remote port: %v", err) + } + + payload := map[string]any{ + "name": "added-node", + "scheme": "http", + "address": host, + "port": port, + "basePath": "/", + "apiToken": "input-secret-token", + "enable": true, + "allowPrivateAddress": true, + } + raw, _ := json.Marshal(payload) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/panel/api/nodes/add", strings.NewReader(string(raw))) + req.Header.Set("Content-Type", "application/json") + engine.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("add status = %d body=%s", w.Code, w.Body.String()) + } + body := w.Body.String() + if strings.Contains(body, "input-secret-token") || strings.Contains(body, "apiToken") { + t.Fatalf("add response leaked api token: %s", body) + } + if !strings.Contains(body, `"hasApiToken":true`) { + t.Fatalf("add response did not expose credential presence: %s", body) + } + + var stored model.Node + if err := database.GetDB().Where("name = ?", "added-node").First(&stored).Error; err != nil { + t.Fatalf("load stored node: %v", err) + } + if stored.ApiToken != "input-secret-token" { + t.Fatalf("stored token = %q, want input-secret-token", stored.ApiToken) + } +} diff --git a/internal/web/service/node.go b/internal/web/service/node.go index b3cbfef90..5456efa2b 100644 --- a/internal/web/service/node.go +++ b/internal/web/service/node.go @@ -336,6 +336,14 @@ func (s *NodeService) GetById(id int) (*model.Node, error) { return n, nil } +func (s *NodeService) GetViewById(id int) (*NodeView, error) { + n, err := s.GetById(id) + if err != nil { + return nil, err + } + return toNodeView(n), nil +} + // NodeExists reports whether a node with the given id exists on this panel. // Used to drop stale, cross-panel node references on inbound import. A Count // query distinguishes "no such node" (count 0, no error) from a real DB error. @@ -424,6 +432,17 @@ func (s *NodeService) Create(n *model.Node) error { return db.Create(n).Error } +func (s *NodeService) CreateFromRequest(req *NodeMutationRequest) (*NodeView, error) { + if err := req.validateCredentials(true); err != nil { + return nil, err + } + n := req.toNode() + if err := s.Create(n); err != nil { + return nil, err + } + return toNodeView(n), nil +} + func (s *NodeService) Update(id int, in *model.Node) error { if err := s.normalize(in); err != nil { return err @@ -465,6 +484,104 @@ func (s *NodeService) Update(id int, in *model.Node) error { return nil } +func (s *NodeService) UpdateFromRequest(id int, req *NodeMutationRequest) error { + if err := req.validateCredentials(false); err != nil { + return err + } + in := req.toNode() + if err := s.normalize(in); err != nil { + return err + } + inboundTagsJSON, err := json.Marshal(in.InboundTags) + if err != nil { + return err + } + db := database.GetDB() + existing := &model.Node{} + if err := db.Where("id = ?", id).First(existing).Error; err != nil { + return err + } + apiToken := existing.ApiToken + switch { + case req.ClearApiToken: + apiToken = "" + case req.ApiToken != nil: + apiToken = *req.ApiToken + } + updates := map[string]any{ + "name": in.Name, + "remark": in.Remark, + "scheme": in.Scheme, + "address": in.Address, + "port": in.Port, + "base_path": in.BasePath, + "api_token": apiToken, + "enable": in.Enable, + "allow_private_address": in.AllowPrivateAddress, + "tls_verify_mode": in.TlsVerifyMode, + "pinned_cert_sha256": in.PinnedCertSha256, + "inbound_sync_mode": in.InboundSyncMode, + "inbound_tags": string(inboundTagsJSON), + "outbound_tag": in.OutboundTag, + } + if err := db.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil { + return err + } + if dErr := s.MarkNodeDirty(id); dErr != nil { + logger.Warning("mark node dirty after update failed:", dErr) + } + if mgr := runtime.GetManager(); mgr != nil { + mgr.InvalidateNode(id) + } + return nil +} + +func (s *NodeService) RuntimeNodeFromRequest(id int, req *NodeMutationRequest) (*model.Node, error) { + if err := req.validateCredentials(id == 0); err != nil { + return nil, err + } + var n *model.Node + if id > 0 { + existing, err := s.GetById(id) + if err != nil { + return nil, err + } + n = existing + } else { + n = &model.Node{} + } + overlay := req.toNode() + overlay.Id = id + if req.ApiToken == nil { + overlay.ApiToken = n.ApiToken + } + if req.ClearApiToken { + overlay.ApiToken = "" + } + *n = *overlay + if err := s.normalize(n); err != nil { + return nil, err + } + return n, nil +} + +func (s *NodeService) NodeFromRequestForCertificate(req *NodeMutationRequest) (*model.Node, error) { + if req == nil { + return nil, common.NewError("node request is required") + } + n := req.toNode() + if n.Scheme == "" { + n.Scheme = "https" + } + if n.BasePath == "" { + n.BasePath = "/" + } + if err := s.normalize(n); err != nil { + return nil, err + } + return n, nil +} + func (s *NodeService) GetRemoteInboundOptions(ctx context.Context, n *model.Node) ([]runtime.RemoteInboundOption, error) { if err := s.normalize(n); err != nil { return nil, err diff --git a/internal/web/service/node_contract.go b/internal/web/service/node_contract.go new file mode 100644 index 000000000..cc2d99ebf --- /dev/null +++ b/internal/web/service/node_contract.go @@ -0,0 +1,183 @@ +package service + +import ( + "strings" + + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/util/common" +) + +// NodeView is the browser/API read contract for nodes. Credentials are +// write-only: responses expose only whether a node has a token configured. +type NodeView struct { + Id int `json:"id"` + Name string `json:"name"` + Remark string `json:"remark"` + Scheme string `json:"scheme"` + Address string `json:"address"` + Port int `json:"port"` + BasePath string `json:"basePath"` + HasApiToken bool `json:"hasApiToken"` + Enable bool `json:"enable"` + AllowPrivateAddress bool `json:"allowPrivateAddress"` + TlsVerifyMode string `json:"tlsVerifyMode"` + PinnedCertSha256 string `json:"pinnedCertSha256"` + InboundSyncMode string `json:"inboundSyncMode"` + InboundTags []string `json:"inboundTags"` + OutboundTag string `json:"outboundTag"` + Guid string `json:"guid"` + Status string `json:"status"` + LastHeartbeat int64 `json:"lastHeartbeat"` + LatencyMs int `json:"latencyMs"` + XrayVersion string `json:"xrayVersion"` + PanelVersion string `json:"panelVersion"` + CpuPct float64 `json:"cpuPct"` + MemPct float64 `json:"memPct"` + UptimeSecs uint64 `json:"uptimeSecs"` + NetUp uint64 `json:"netUp"` + NetDown uint64 `json:"netDown"` + LastError string `json:"lastError"` + XrayState string `json:"xrayState"` + XrayError string `json:"xrayError"` + ConfigDirty bool `json:"configDirty"` + ConfigDirtyAt int64 `json:"configDirtyAt"` + InboundCount int `json:"inboundCount"` + ClientCount int `json:"clientCount"` + OnlineCount int `json:"onlineCount"` + ActiveCount int `json:"activeCount"` + DisabledCount int `json:"disabledCount"` + DepletedCount int `json:"depletedCount"` + ParentGuid string `json:"parentGuid,omitempty"` + Transitive bool `json:"transitive,omitempty"` + CreatedAt int64 `json:"createdAt"` + UpdatedAt int64 `json:"updatedAt"` +} + +func toNodeView(n *model.Node) *NodeView { + if n == nil { + return nil + } + return &NodeView{ + Id: n.Id, + Name: n.Name, + Remark: n.Remark, + Scheme: n.Scheme, + Address: n.Address, + Port: n.Port, + BasePath: n.BasePath, + HasApiToken: n.ApiToken != "", + Enable: n.Enable, + AllowPrivateAddress: n.AllowPrivateAddress, + TlsVerifyMode: n.TlsVerifyMode, + PinnedCertSha256: n.PinnedCertSha256, + InboundSyncMode: n.InboundSyncMode, + InboundTags: n.InboundTags, + OutboundTag: n.OutboundTag, + Guid: n.Guid, + Status: n.Status, + LastHeartbeat: n.LastHeartbeat, + LatencyMs: n.LatencyMs, + XrayVersion: n.XrayVersion, + PanelVersion: n.PanelVersion, + CpuPct: n.CpuPct, + MemPct: n.MemPct, + UptimeSecs: n.UptimeSecs, + NetUp: n.NetUp, + NetDown: n.NetDown, + LastError: n.LastError, + XrayState: n.XrayState, + XrayError: n.XrayError, + ConfigDirty: n.ConfigDirty, + ConfigDirtyAt: n.ConfigDirtyAt, + InboundCount: n.InboundCount, + ClientCount: n.ClientCount, + OnlineCount: n.OnlineCount, + ActiveCount: n.ActiveCount, + DisabledCount: n.DisabledCount, + DepletedCount: n.DepletedCount, + ParentGuid: n.ParentGuid, + Transitive: n.Transitive, + CreatedAt: n.CreatedAt, + UpdatedAt: n.UpdatedAt, + } +} + +func toNodeViews(nodes []*model.Node) []*NodeView { + views := make([]*NodeView, 0, len(nodes)) + for _, node := range nodes { + views = append(views, toNodeView(node)) + } + return views +} + +// NodeMutationRequest is the node write/probe contract. ApiToken is accepted +// only as input. On update, nil means keep the stored token; replacement and +// clearing are explicit and mutually exclusive. +type NodeMutationRequest struct { + Id int `json:"id" form:"id"` + Name string `json:"name" form:"name" validate:"required"` + Remark string `json:"remark" form:"remark"` + Scheme string `json:"scheme" form:"scheme" validate:"omitempty,oneof=http https"` + Address string `json:"address" form:"address" validate:"required"` + Port int `json:"port" form:"port" validate:"gte=1,lte=65535"` + BasePath string `json:"basePath" form:"basePath"` + ApiToken *string `json:"apiToken,omitempty" form:"apiToken"` + ClearApiToken bool `json:"clearApiToken,omitempty" form:"clearApiToken"` + Enable bool `json:"enable" form:"enable"` + AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress"` + TlsVerifyMode string `json:"tlsVerifyMode" form:"tlsVerifyMode" validate:"omitempty,oneof=verify skip pin mtls"` + PinnedCertSha256 string `json:"pinnedCertSha256" form:"pinnedCertSha256"` + InboundSyncMode string `json:"inboundSyncMode" form:"inboundSyncMode" validate:"omitempty,oneof=all selected"` + InboundTags []string `json:"inboundTags" form:"inboundTags"` + OutboundTag string `json:"outboundTag" form:"outboundTag"` +} + +func (r *NodeMutationRequest) validateCredentials(create bool) error { + if r == nil { + return common.NewError("node request is required") + } + if r.ApiToken != nil && r.ClearApiToken { + return common.NewError("apiToken and clearApiToken are mutually exclusive") + } + if r.ApiToken != nil { + *r.ApiToken = strings.TrimSpace(*r.ApiToken) + if *r.ApiToken == "" { + return common.NewError("apiToken must be omitted to keep it or cleared explicitly") + } + } + if create { + if r.ClearApiToken { + return common.NewError("credentials cannot be cleared while creating a node") + } + if r.ApiToken == nil && r.TlsVerifyMode != "mtls" { + return common.NewError("apiToken is required unless mtls is enabled") + } + } + if r.ClearApiToken && r.Enable && r.TlsVerifyMode != "mtls" { + return common.NewError("disable the node or enable mtls before clearing its apiToken") + } + return nil +} + +func (r *NodeMutationRequest) toNode() *model.Node { + n := &model.Node{ + Id: r.Id, + Name: r.Name, + Remark: r.Remark, + Scheme: r.Scheme, + Address: r.Address, + Port: r.Port, + BasePath: r.BasePath, + Enable: r.Enable, + AllowPrivateAddress: r.AllowPrivateAddress, + TlsVerifyMode: r.TlsVerifyMode, + PinnedCertSha256: r.PinnedCertSha256, + InboundSyncMode: r.InboundSyncMode, + InboundTags: r.InboundTags, + OutboundTag: r.OutboundTag, + } + if r.ApiToken != nil { + n.ApiToken = *r.ApiToken + } + return n +} diff --git a/internal/web/service/node_credentials_writeonly_test.go b/internal/web/service/node_credentials_writeonly_test.go new file mode 100644 index 000000000..4c3f6582c --- /dev/null +++ b/internal/web/service/node_credentials_writeonly_test.go @@ -0,0 +1,168 @@ +package service + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +func TestNodeCredentialsNeverMarshal(t *testing.T) { + raw, err := json.Marshal(&model.Node{ + Id: 7, + Name: "node", + ApiToken: "plain-secret-token", + }) + if err != nil { + t.Fatalf("marshal node: %v", err) + } + out := string(raw) + if strings.Contains(out, "plain-secret-token") || strings.Contains(out, "apiToken") { + t.Fatalf("model.Node JSON leaked api token field: %s", out) + } +} + +func TestNodeViewExposesOnlyCredentialPresence(t *testing.T) { + setupConflictDB(t) + + svc := &NodeService{} + reqToken := "write-only-secret" + view, err := svc.CreateFromRequest(&NodeMutationRequest{ + Name: "node-view", + Scheme: "https", + Address: "127.0.0.1", + Port: 2096, + ApiToken: &reqToken, + Enable: true, + }) + if err != nil { + t.Fatalf("create from request: %v", err) + } + if !view.HasApiToken { + t.Fatal("create view should report credential presence") + } + + got, err := svc.GetViewById(view.Id) + if err != nil { + t.Fatalf("get view: %v", err) + } + raw, err := json.Marshal(got) + if err != nil { + t.Fatalf("marshal view: %v", err) + } + out := string(raw) + if !strings.Contains(out, `"hasApiToken":true`) { + t.Fatalf("view does not report credential presence: %s", out) + } + if strings.Contains(out, reqToken) || strings.Contains(out, "apiToken") { + t.Fatalf("NodeView leaked plaintext or apiToken key: %s", out) + } +} + +func TestNodeCredentialMutationSemantics(t *testing.T) { + setupConflictDB(t) + svc := &NodeService{} + + initial := "initial-token" + view, err := svc.CreateFromRequest(&NodeMutationRequest{ + Name: "mut", + Scheme: "https", + Address: "127.0.0.1", + Port: 2096, + ApiToken: &initial, + Enable: true, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + before := rawStoredNodeToken(t, view.Id) + if before != initial { + t.Fatalf("stored token = %q, want %q", before, initial) + } + + if err := svc.UpdateFromRequest(view.Id, &NodeMutationRequest{ + Name: "mut-renamed", + Scheme: "https", + Address: "127.0.0.1", + Port: 2096, + Enable: true, + }); err != nil { + t.Fatalf("keep-token update: %v", err) + } + if after := rawStoredNodeToken(t, view.Id); after != before { + t.Fatalf("omitted token should keep existing token: %q -> %q", before, after) + } + + blank := " " + if err := svc.UpdateFromRequest(view.Id, &NodeMutationRequest{ + Name: "bad", + Scheme: "https", + Address: "127.0.0.1", + Port: 2096, + ApiToken: &blank, + Enable: true, + }); err == nil { + t.Fatal("blank apiToken must be rejected; omit to keep or clear explicitly") + } + + next := "next-token" + if err := svc.UpdateFromRequest(view.Id, &NodeMutationRequest{ + Name: "mut", + Scheme: "https", + Address: "127.0.0.1", + Port: 2096, + ApiToken: &next, + Enable: true, + }); err != nil { + t.Fatalf("replace token: %v", err) + } + if replaced := rawStoredNodeToken(t, view.Id); replaced != next { + t.Fatalf("replace token stored %q, want %q", replaced, next) + } + + if err := svc.UpdateFromRequest(view.Id, &NodeMutationRequest{ + Name: "mut", + Scheme: "https", + Address: "127.0.0.1", + Port: 2096, + ClearApiToken: true, + Enable: true, + }); err == nil { + t.Fatal("enabled non-mtls node must not clear apiToken") + } + if err := svc.UpdateFromRequest(view.Id, &NodeMutationRequest{ + Name: "mut", + Scheme: "https", + Address: "127.0.0.1", + Port: 2096, + ClearApiToken: true, + Enable: false, + }); err != nil { + t.Fatalf("clear disabled token: %v", err) + } + if cleared := rawStoredNodeToken(t, view.Id); cleared != "" { + t.Fatalf("clear token left stored value %q", cleared) + } + + if _, err := svc.CreateFromRequest(&NodeMutationRequest{ + Name: "mtls-only", + Scheme: "https", + Address: "127.0.0.1", + Port: 2097, + Enable: true, + TlsVerifyMode: "mtls", + }); err != nil { + t.Fatalf("mtls create without token: %v", err) + } +} + +func rawStoredNodeToken(t *testing.T, id int) string { + t.Helper() + var n model.Node + if err := database.GetDB().Select("api_token").Where("id = ?", id).First(&n).Error; err != nil { + t.Fatalf("load raw node token: %v", err) + } + return n.ApiToken +} diff --git a/internal/web/service/node_tree.go b/internal/web/service/node_tree.go index 36cc73518..ec89ba384 100644 --- a/internal/web/service/node_tree.go +++ b/internal/web/service/node_tree.go @@ -157,6 +157,14 @@ func (s *NodeService) GetNodeTree() ([]*model.Node, error) { return all, nil } +func (s *NodeService) GetNodeTreeView() ([]*NodeView, error) { + nodes, err := s.GetNodeTree() + if err != nil { + return nil, err + } + return toNodeViews(nodes), nil +} + // recountByGuid recomputes InboundCount/OnlineCount/DepletedCount for every node // in the tree, keyed by the GUID that physically hosts each inbound, so a direct // node shows only its own inbounds and each transitive node shows its own