feat(nodes): 第19条 agent token 可恢复保存 + 老节点回填 + 安装命令接口
- AgentNode 新增 TokenPlain(json:"-" 永不序列化):建节点/签发 token
时一并存明文,用户已明确接受"可恢复保存换便利"的口径
- 回填:findAgent 在 token 通过哈希校验后,若该行尚无明文则把本次
请求携带的 token 回填存下(带 token_plain='' 守卫的 UPDATE,
已有明文绝不被请求覆盖;只有面板侧签发流程写新值);鉴权语义不变
- 新增管理端点 GET /panel/api/agent/install/:nodeId 返回
{nodeId,name,token,hasToken},供编辑页重显完整安装命令;老节点
未回填时 hasToken=false,前端展示无 token 命令 + 本机路径提示
- 单测覆盖:签发存明文 / 回填后接口可读出完整 token / 校验失败
绝不回填 / 已有明文不被覆盖
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user