mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-28 04:00:57 +00:00
fix(nodes): make node API tokens write-only
This commit is contained in:
parent
b1fb39c486
commit
a4f815cc9a
7 changed files with 643 additions and 40 deletions
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
124
internal/web/controller/node_credentials_writeonly_test.go
Normal file
124
internal/web/controller/node_credentials_writeonly_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
183
internal/web/service/node_contract.go
Normal file
183
internal/web/service/node_contract.go
Normal file
|
|
@ -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
|
||||
}
|
||||
168
internal/web/service/node_credentials_writeonly_test.go
Normal file
168
internal/web/service/node_credentials_writeonly_test.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue