fix(nodes): make node API tokens write-only (#5613)

* fix(nodes): make node API tokens write-only

* fix(nodes): keep token optional on edit for write-only API tokens

NodeView no longer returns apiToken, so the edit form must consume hasApiToken and not require re-entering the token. Relaxes the form validation on edit, adds a keep-current placeholder, and adds the i18n key to all 13 locales.
This commit is contained in:
n0ctal
2026-07-23 18:35:11 +05:00
committed by GitHub
parent 892c06c8bc
commit c77608bc47
29 changed files with 1716 additions and 88 deletions
+42 -39
View File
@@ -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
@@ -0,0 +1,192 @@
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)
}
}
func TestNodeControllerUpdateBlankApiTokenKeepsStoredToken(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 stored-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)
}
node := &model.Node{
Name: "stored-node",
Scheme: "http",
Address: host,
Port: port,
BasePath: "/",
ApiToken: "stored-secret-token",
Enable: true,
AllowPrivateAddress: true,
}
if err := database.GetDB().Create(node).Error; err != nil {
t.Fatalf("seed node: %v", err)
}
payload := map[string]any{
"name": "stored-node-renamed",
"scheme": "http",
"address": host,
"port": port,
"basePath": "/",
"apiToken": "",
"enable": true,
"allowPrivateAddress": true,
}
raw, _ := json.Marshal(payload)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/panel/api/nodes/update/"+strconv.Itoa(node.Id), strings.NewReader(string(raw)))
req.Header.Set("Content-Type", "application/json")
engine.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("update status = %d body=%s", w.Code, w.Body.String())
}
var stored model.Node
if err := database.GetDB().Where("id = ?", node.Id).First(&stored).Error; err != nil {
t.Fatalf("load stored node: %v", err)
}
if stored.ApiToken != "stored-secret-token" {
t.Fatalf("blank update changed token to %q", stored.ApiToken)
}
if stored.Name != "stored-node-renamed" {
t.Fatalf("stored name = %q, want stored-node-renamed", stored.Name)
}
}
+123
View File
@@ -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
@@ -467,6 +486,110 @@ 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
}
if apiToken == "" && in.Enable && in.TlsVerifyMode != "mtls" {
return common.NewError("apiToken is required unless mtls is enabled")
}
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
}
if n.ApiToken == "" && n.Enable && n.TlsVerifyMode != "mtls" {
return nil, common.NewError("apiToken is required unless mtls is enabled")
}
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
+186
View File
@@ -0,0 +1,186 @@
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" example:"1"`
Name string `json:"name" example:"edge-1"`
Remark string `json:"remark" example:"Primary edge"`
Scheme string `json:"scheme" example:"https"`
Address string `json:"address" example:"node.example.com"`
Port int `json:"port" example:"2053"`
BasePath string `json:"basePath" example:"/"`
HasApiToken bool `json:"hasApiToken" example:"true"`
Enable bool `json:"enable" example:"true"`
AllowPrivateAddress bool `json:"allowPrivateAddress" example:"false"`
TlsVerifyMode string `json:"tlsVerifyMode" example:"verify"`
PinnedCertSha256 string `json:"pinnedCertSha256" example:""`
InboundSyncMode string `json:"inboundSyncMode" example:"all"`
InboundTags []string `json:"inboundTags" example:"[\"in-443-tcp\"]"`
OutboundTag string `json:"outboundTag" example:"direct"`
Guid string `json:"guid" example:"node-guid"`
Status string `json:"status" example:"online"`
LastHeartbeat int64 `json:"lastHeartbeat" example:"1700000000"`
LatencyMs int `json:"latencyMs" example:"42"`
XrayVersion string `json:"xrayVersion" example:"25.10.31"`
PanelVersion string `json:"panelVersion" example:"v3.x.x"`
CpuPct float64 `json:"cpuPct" example:"12.5"`
MemPct float64 `json:"memPct" example:"45.2"`
UptimeSecs uint64 `json:"uptimeSecs" example:"86400"`
NetUp uint64 `json:"netUp" example:"2097152"`
NetDown uint64 `json:"netDown" example:"1048576"`
LastError string `json:"lastError" example:""`
XrayState string `json:"xrayState" example:"running"`
XrayError string `json:"xrayError" example:""`
ConfigDirty bool `json:"configDirty" example:"false"`
ConfigDirtyAt int64 `json:"configDirtyAt" example:"0"`
InboundCount int `json:"inboundCount" example:"3"`
ClientCount int `json:"clientCount" example:"25"`
OnlineCount int `json:"onlineCount" example:"5"`
ActiveCount int `json:"activeCount" example:"20"`
DisabledCount int `json:"disabledCount" example:"2"`
DepletedCount int `json:"depletedCount" example:"1"`
ParentGuid string `json:"parentGuid,omitempty" example:""`
Transitive bool `json:"transitive,omitempty" example:"false"`
CreatedAt int64 `json:"createdAt" example:"1700000000"`
UpdatedAt int64 `json:"updatedAt" example:"1700003600"`
}
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 == "" {
if create {
return common.NewError("apiToken is required unless mtls is enabled")
}
r.ApiToken = nil
}
}
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
}
@@ -0,0 +1,201 @@
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: "mut-blank",
Scheme: "https",
Address: "127.0.0.1",
Port: 2096,
ApiToken: &blank,
Enable: true,
}); err != nil {
t.Fatalf("blank apiToken should keep existing token on update: %v", err)
}
if afterBlank := rawStoredNodeToken(t, view.Id); afterBlank != before {
t.Fatalf("blank token should keep existing token: %q -> %q", before, afterBlank)
}
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 TestNodeUpdateRequiresTokenWhenNoStoredTokenAndMtlsDisabled(t *testing.T) {
setupConflictDB(t)
svc := &NodeService{}
view, err := svc.CreateFromRequest(&NodeMutationRequest{
Name: "mtls-empty",
Scheme: "https",
Address: "127.0.0.1",
Port: 2098,
Enable: true,
TlsVerifyMode: "mtls",
})
if err != nil {
t.Fatalf("create mtls node: %v", err)
}
blank := ""
if err := svc.UpdateFromRequest(view.Id, &NodeMutationRequest{
Name: "mtls-empty",
Scheme: "https",
Address: "127.0.0.1",
Port: 2098,
ApiToken: &blank,
Enable: true,
BasePath: "/",
OutboundTag: "",
}); err == nil {
t.Fatal("enabled non-mtls node without stored token must be rejected")
}
}
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
}
+8
View File
@@ -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
+1
View File
@@ -1007,6 +1007,7 @@
"apiToken": "رمز API",
"apiTokenPlaceholder": "التوكن من صفحة إعدادات البانل البعيد",
"apiTokenHint": "البانل البعيد بيعرض توكن API بتاعه في المصادقة → توكن API.",
"apiTokenKeepHint": "اتركه فارغًا للإبقاء على التوكن الحالي",
"regenerate": "تجديد التوكن",
"regenerateConfirm": "تجديد التوكن هيلغي التوكن الحالي. أي بانل مركزي بيستخدمه هيفقد الصلاحية لحد ما تحدّث التوكن. تكمّل؟",
"allowPrivateAddress": "السماح بالعنوان الخاص",
+1
View File
@@ -1124,6 +1124,7 @@
"apiToken": "API Token",
"apiTokenPlaceholder": "Token from the remote panel's Settings page",
"apiTokenHint": "The remote panel exposes its API token under Authentication → API Token.",
"apiTokenKeepHint": "Leave blank to keep the current token",
"regenerate": "Regenerate Token",
"regenerateConfirm": "Regenerating invalidates the current token. Any central panel using it will lose access until updated. Continue?",
"allowPrivateAddress": "Allow private address",
+1
View File
@@ -1007,6 +1007,7 @@
"apiToken": "Token API",
"apiTokenPlaceholder": "Token desde la página de Configuración del panel remoto",
"apiTokenHint": "El panel remoto expone su token de API en Configuraciones de Seguridad → Token de API.",
"apiTokenKeepHint": "Déjalo en blanco para mantener el token actual",
"regenerate": "Regenerar token",
"regenerateConfirm": "Regenerar invalida el token actual. Cualquier panel central que lo use perderá el acceso hasta que se actualice. ¿Continuar?",
"allowPrivateAddress": "Permitir dirección privada",
+1
View File
@@ -1007,6 +1007,7 @@
"apiToken": "توکن API",
"apiTokenPlaceholder": "توکن از صفحه تنظیمات پنل ریموت",
"apiTokenHint": "پنل ریموت توکن API خودش را در بخش احرازهویت → توکن API نمایش می‌دهد.",
"apiTokenKeepHint": "برای حفظ توکن فعلی خالی بگذارید",
"regenerate": "تولید مجدد توکن",
"regenerateConfirm": "تولید مجدد، توکن فعلی را باطل می‌کند. هر پنل مرکزی‌ای که از این توکن استفاده می‌کند تا زمان به‌روزرسانی، دسترسی‌اش قطع می‌شود. ادامه می‌دهید؟",
"allowPrivateAddress": "اجازه آدرس خصوصی",
+1
View File
@@ -1007,6 +1007,7 @@
"apiToken": "Token API",
"apiTokenPlaceholder": "Token dari halaman Pengaturan panel jarak jauh",
"apiTokenHint": "Panel jarak jauh menampilkan token API-nya di Otentikasi → Token API.",
"apiTokenKeepHint": "Biarkan kosong untuk mempertahankan token saat ini",
"regenerate": "Buat Ulang Token",
"regenerateConfirm": "Membuat ulang akan membatalkan token saat ini. Setiap panel pusat yang menggunakannya akan kehilangan akses sampai diperbarui. Lanjutkan?",
"allowPrivateAddress": "Izinkan alamat pribadi",
+1
View File
@@ -1007,6 +1007,7 @@
"apiToken": "API トークン",
"apiTokenPlaceholder": "リモートパネルの設定ページから取得したトークン",
"apiTokenHint": "リモートパネルでは、セキュリティ設定 → APIトークン でAPIトークンを確認できます。",
"apiTokenKeepHint": "現在のトークンを保持するには空欄のままにします",
"regenerate": "トークンを再生成",
"regenerateConfirm": "再生成すると現在のトークンは無効になります。これを使用しているすべての中央パネルは更新されるまでアクセスできなくなります。続行しますか?",
"allowPrivateAddress": "プライベートアドレスを許可",
+1
View File
@@ -1007,6 +1007,7 @@
"apiToken": "Token API",
"apiTokenPlaceholder": "Token da página de Configurações do painel remoto",
"apiTokenHint": "O painel remoto exibe o token da API em Autenticação → Token da API.",
"apiTokenKeepHint": "Deixe em branco para manter o token atual",
"regenerate": "Regenerar token",
"regenerateConfirm": "Regenerar invalida o token atual. Qualquer painel central que o utilize perderá acesso até ser atualizado. Continuar?",
"allowPrivateAddress": "Permitir endereço privado",
+1
View File
@@ -1007,6 +1007,7 @@
"apiToken": "API Токен",
"apiTokenPlaceholder": "Токен со страницы Настроек удалённой панели",
"apiTokenHint": "Удалённая панель показывает свой токен API в разделе Учетная запись → Токен API.",
"apiTokenKeepHint": "Оставьте пустым, чтобы сохранить текущий токен",
"regenerate": "Сгенерировать токен заново",
"regenerateConfirm": "Повторная генерация аннулирует текущий токен. Любая центральная панель, использующая его, потеряет доступ до обновления. Продолжить?",
"allowPrivateAddress": "Разрешить частный адрес",
+1
View File
@@ -1007,6 +1007,7 @@
"apiToken": "API Token",
"apiTokenPlaceholder": "Uzak panelin Ayarlar sayfasındaki token",
"apiTokenHint": "Uzak panel API token'ını Kimlik Doğrulama → API Token altında gösterir.",
"apiTokenKeepHint": "Mevcut token'ı korumak için boş bırakın",
"regenerate": "Token'ı Yeniden Oluştur",
"regenerateConfirm": "Yeniden oluşturmak mevcut token'ı geçersiz kılar. Onu kullanan tüm merkezi paneller, güncellenene kadar erişimini kaybeder. Devam edilsin mi?",
"allowPrivateAddress": "Özel Adrese İzin Ver",
+1
View File
@@ -1007,6 +1007,7 @@
"apiToken": "API Токен",
"apiTokenPlaceholder": "Токен зі сторінки Налаштувань віддаленої панелі",
"apiTokenHint": "Віддалена панель показує свій токен API в Автентифікація → Токен API.",
"apiTokenKeepHint": "Залиште порожнім, щоб зберегти поточний токен",
"regenerate": "Перегенерувати токен",
"regenerateConfirm": "Перегенерація скасовує поточний токен. Будь-яка центральна панель, що його використовує, втратить доступ до оновлення. Продовжити?",
"allowPrivateAddress": "Дозволити приватну адресу",
+1
View File
@@ -1007,6 +1007,7 @@
"apiToken": "Token API",
"apiTokenPlaceholder": "Token từ trang Cài đặt của panel từ xa",
"apiTokenHint": "Panel từ xa hiển thị token API tại Bảo mật → Token API.",
"apiTokenKeepHint": "Để trống để giữ token hiện tại",
"regenerate": "Tạo lại token",
"regenerateConfirm": "Tạo lại sẽ vô hiệu hóa token hiện tại. Mọi panel trung tâm dùng nó sẽ mất quyền truy cập cho đến khi được cập nhật. Tiếp tục?",
"allowPrivateAddress": "Cho phép địa chỉ riêng",
+1
View File
@@ -1007,6 +1007,7 @@
"apiToken": "API 令牌",
"apiTokenPlaceholder": "远程面板设置页中的令牌",
"apiTokenHint": "远程面板在 安全设定 → API 令牌 中显示其 API 令牌。",
"apiTokenKeepHint": "留空以保留当前令牌",
"regenerate": "重新生成令牌",
"regenerateConfirm": "重新生成会使当前令牌失效。任何使用该令牌的中央面板都会失去访问权限,直至更新。是否继续?",
"allowPrivateAddress": "允许私有地址",
+1
View File
@@ -1007,6 +1007,7 @@
"apiToken": "API 權杖",
"apiTokenPlaceholder": "遠端面板設定頁中的權杖",
"apiTokenHint": "遠端面板在 安全設定 → API 權杖 中顯示其 API 權杖。",
"apiTokenKeepHint": "留空以保留目前的權杖",
"regenerate": "重新產生權杖",
"regenerateConfirm": "重新產生會使目前的權杖失效。任何使用該權杖的中央面板將失去存取權,直到更新為止。是否繼續?",
"allowPrivateAddress": "允許私有地址",