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:
Muse
2026-10-22 13:04:33 +00:00
parent 1edb8f71da
commit 29b724dae1
3 changed files with 205 additions and 4 deletions
+45 -3
View File
@@ -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