diff --git a/internal/database/model/agent_node.go b/internal/database/model/agent_node.go index 1fd6482..0adbcca 100644 --- a/internal/database/model/agent_node.go +++ b/internal/database/model/agent_node.go @@ -4,11 +4,20 @@ package model // 3x-ui node row and carries the per-agent egress configuration. // // Agents are the only writers of their own LastSeen; the panel owns everything -// else. Raw tokens are never stored — only the SHA-256 hex of the token. +// else. Authentication uses only the SHA-256 hex of the token; since the +// next-core batch (item 19) the raw token is additionally kept recoverably +// in TokenPlain — an operator-accepted tradeoff so the node edit page can +// re-display the full install command. type AgentNode struct { Id int `json:"id" gorm:"primaryKey;autoIncrement"` TokenHash string `json:"-" gorm:"size:64;uniqueIndex"` TokenPrefix string `json:"tokenPrefix" gorm:"size:16;index"` + // TokenPlain is a recoverable copy of the raw token: stored at + // issuance, and backfilled from the first authenticated agent request + // for rows created before this field existed (item 19). Existing + // values are never overwritten by requests; only issuance (create / + // panel-side re-issue) writes a fresh one. Never serialized. + TokenPlain string `json:"-" gorm:"type:text"` Name string `json:"name"` // NodeID is the 3x-ui node whose inbounds this agent serves. Nil means the // token was issued but not yet bound to a node. diff --git a/internal/web/controller/agent.go b/internal/web/controller/agent.go index c2a69e8..f5d5aa2 100644 --- a/internal/web/controller/agent.go +++ b/internal/web/controller/agent.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "encoding/json" "errors" + "strconv" "time" "github.com/mhsanaei/3x-ui/v3/internal/database" @@ -51,6 +52,7 @@ func (a *AgentController) RegisterAdminRoutes(g *gin.RouterGroup) { g.DELETE("/tokens/:id", a.deleteToken) g.GET("/egress/:token", a.getEgress) g.PUT("/egress/:token", a.setEgress) + g.GET("/install/:nodeId", a.getAgentInstall) } func hashAgentToken(token string) string { @@ -76,6 +78,18 @@ func (a *AgentController) findAgent(token string) (*model.AgentNode, bool) { if err := database.GetDB().Where("token_hash = ?", hashAgentToken(token)).First(&agent).Error; err != nil { return nil, false } + // Item 19 backfill: the token has just passed the hash check, so for + // rows created before plaintext storage existed, adopt the presented + // token as the recoverable copy. The guarded update only fires while + // token_plain is empty — an authenticated request can never overwrite + // an already-stored token (only issuance writes a fresh one). + if agent.TokenPlain == "" { + if res := database.GetDB().Model(&model.AgentNode{}). + Where("id = ? AND token_plain = ''", agent.Id). + Update("token_plain", token); res.Error == nil && res.RowsAffected > 0 { + agent.TokenPlain = token + } + } return &agent, true } @@ -278,8 +292,9 @@ func (a *AgentController) reportTraffic(c *gin.Context) { jsonObj(c, gin.H{"message": "ok"}, nil) } -// issueAgentToken generates a new agent token bound to nodeID and stores only -// its hash. The raw token is returned once and must be shown to the operator. +// issueAgentToken generates a new agent token bound to nodeID. The hash is +// what authenticates; the raw token is also kept recoverably (TokenPlain, +// item 19) so the node edit page can re-display the install command. func issueAgentToken(nodeID *int, name string) (string, int, error) { raw := make([]byte, 32) if _, err := rand.Read(raw); err != nil { @@ -289,6 +304,7 @@ func issueAgentToken(nodeID *int, name string) (string, int, error) { agent := model.AgentNode{ TokenHash: hashAgentToken(token), TokenPrefix: token[:12], + TokenPlain: token, Name: name, NodeID: nodeID, } @@ -299,7 +315,7 @@ func issueAgentToken(nodeID *int, name string) (string, int, error) { } // createToken issues a new agent token bound to a node. The raw token is -// returned once; only its hash is stored. +// returned in this response and kept recoverably for the install command. func (a *AgentController) createToken(c *gin.Context) { var req struct { NodeID *int `json:"node_id"` @@ -332,6 +348,32 @@ func (a *AgentController) createToken(c *gin.Context) { }, nil) } +// getAgentInstall returns what the node edit page needs to re-display the +// full install command for an agent node (item 19): the agent's name and, +// when the master holds a recoverable copy, its raw token. Legacy rows +// whose token has not been backfilled yet answer hasToken=false with an +// empty token — the UI then shows the command without the token and points +// at /etc/x-ui/agent.json on the node itself. +func (a *AgentController) getAgentInstall(c *gin.Context) { + nodeID, err := strconv.Atoi(c.Param("nodeId")) + if err != nil { + jsonMsg(c, "Invalid node id", errRequest) + return + } + var agent model.AgentNode + if err := database.GetDB().Where("node_id = ?", nodeID). + Order("last_seen DESC, id DESC").First(&agent).Error; err != nil { + jsonMsg(c, "Agent not found for node", errRequest) + return + } + jsonObj(c, gin.H{ + "nodeId": nodeID, + "name": agent.Name, + "token": agent.TokenPlain, + "hasToken": agent.TokenPlain != "", + }, nil) +} + func (a *AgentController) listTokens(c *gin.Context) { var agents []model.AgentNode _ = database.GetDB().Order("id DESC").Find(&agents).Error diff --git a/internal/web/controller/agent_token_backfill_test.go b/internal/web/controller/agent_token_backfill_test.go new file mode 100644 index 0000000..c9ac27a --- /dev/null +++ b/internal/web/controller/agent_token_backfill_test.go @@ -0,0 +1,150 @@ +package controller + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strconv" + "testing" + + "github.com/gin-gonic/gin" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +// Item 19: agent tokens are kept recoverably so the node edit page can +// re-display the full install command. Rows created before plaintext +// storage existed are backfilled from the first request that passes the +// token hash check — and only from such a request. + +func setupBackfillTest(t *testing.T) (*gin.Engine, *model.Node) { + 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() }) + + node := &model.Node{Name: "backfill-node", Address: "10.0.9.2"} + if err := database.GetDB().Create(node).Error; err != nil { + t.Fatalf("seed node: %v", err) + } + engine := gin.New() + ctrl := NewAgentController(engine.Group("/panel/api/agent")) + ctrl.RegisterAdminRoutes(engine.Group("/panel/api/agent")) + return engine, node +} + +func seedLegacyAgent(t *testing.T, nodeID int, token, plain string) *model.AgentNode { + t.Helper() + agent := &model.AgentNode{ + TokenHash: hashAgentToken(token), + TokenPrefix: token[:12], + TokenPlain: plain, + Name: "backfill-node", + NodeID: &nodeID, + } + if err := database.GetDB().Create(agent).Error; err != nil { + t.Fatalf("seed agent: %v", err) + } + return agent +} + +func reloadAgent(t *testing.T, id int) model.AgentNode { + t.Helper() + var row model.AgentNode + if err := database.GetDB().First(&row, id).Error; err != nil { + t.Fatalf("reload agent: %v", err) + } + return row +} + +func heartbeatWith(engine *gin.Engine, token string) int { + req := httptest.NewRequest(http.MethodPost, "/panel/api/agent/heartbeat", bytes.NewBufferString(`{}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + return rec.Code +} + +func TestIssueAgentTokenStoresPlaintext(t *testing.T) { + _, node := setupBackfillTest(t) + token, id, err := issueAgentToken(&node.Id, "backfill-node") + if err != nil { + t.Fatalf("issueAgentToken: %v", err) + } + row := reloadAgent(t, id) + if row.TokenPlain != token { + t.Fatalf("issued token not stored recoverably: TokenPlain=%q", row.TokenPlain) + } + if row.TokenHash != hashAgentToken(token) { + t.Fatal("issued token hash mismatch") + } +} + +func TestAgentTokenBackfillOnAuthenticatedRequest(t *testing.T) { + engine, node := setupBackfillTest(t) + const token = "legacy-token-backfill-00000000000000000001" + agent := seedLegacyAgent(t, node.Id, token, "") + + if code := heartbeatWith(engine, token); code != http.StatusOK { + t.Fatalf("heartbeat with valid token: status %d", code) + } + if row := reloadAgent(t, agent.Id); row.TokenPlain != token { + t.Fatalf("token not backfilled after authenticated request: %q", row.TokenPlain) + } + + // The install endpoint now serves the complete command material. + req := httptest.NewRequest(http.MethodGet, "/panel/api/agent/install/"+strconv.Itoa(node.Id), nil) + rec := httptest.NewRecorder() + engine.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("install endpoint: status %d", rec.Code) + } + var resp struct { + Success bool `json:"success"` + Obj struct { + Name string `json:"name"` + Token string `json:"token"` + HasToken bool `json:"hasToken"` + } `json:"obj"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("install response not JSON: %v", err) + } + if !resp.Success || !resp.Obj.HasToken || resp.Obj.Token != token { + t.Fatalf("install endpoint did not return the backfilled token: %+v", resp.Obj) + } +} + +func TestAgentTokenNoBackfillOnFailedAuth(t *testing.T) { + engine, node := setupBackfillTest(t) + const token = "legacy-token-backfill-00000000000000000002" + agent := seedLegacyAgent(t, node.Id, token, "") + + if code := heartbeatWith(engine, "definitely-the-wrong-token"); code != http.StatusUnauthorized { + t.Fatalf("heartbeat with wrong token: status %d, want 401", code) + } + if row := reloadAgent(t, agent.Id); row.TokenPlain != "" { + t.Fatalf("failed authentication must not backfill, got %q", row.TokenPlain) + } +} + +func TestAgentTokenBackfillNeverOverwrites(t *testing.T) { + engine, node := setupBackfillTest(t) + const token = "legacy-token-backfill-00000000000000000003" + agent := seedLegacyAgent(t, node.Id, token, "previously-stored-value") + + if code := heartbeatWith(engine, token); code != http.StatusOK { + t.Fatalf("heartbeat with valid token: status %d", code) + } + if row := reloadAgent(t, agent.Id); row.TokenPlain != "previously-stored-value" { + t.Fatalf("existing plaintext was overwritten by a request: %q", row.TokenPlain) + } +}