mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-05 15:26:36 +08:00
feat: 添加节点管理功能,支持全局 discovery token 生成与旋转,更新节点注册流程
This commit is contained in:
@@ -47,6 +47,7 @@ var WeChatAccountQRCodeImageURL = ""
|
|||||||
var TurnstileSiteKey = ""
|
var TurnstileSiteKey = ""
|
||||||
var TurnstileSecretKey = ""
|
var TurnstileSecretKey = ""
|
||||||
var AgentToken = ""
|
var AgentToken = ""
|
||||||
|
var AgentDiscoveryToken = ""
|
||||||
var NodeOfflineThreshold = 2 * time.Minute
|
var NodeOfflineThreshold = 2 * time.Minute
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -17,15 +17,15 @@ func AgentRegister(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
discoveryNode, ok := c.Get("discovery_node")
|
var (
|
||||||
if !ok {
|
result *service.AgentRegistrationResponse
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{
|
err error
|
||||||
"success": false,
|
)
|
||||||
"message": "无权进行此操作,Discovery Token 无效",
|
if authNode, ok := c.Get("agent_node"); ok {
|
||||||
})
|
result, err = service.RegisterNodeWithAgentToken(authNode.(*model.Node), payload)
|
||||||
return
|
} else {
|
||||||
|
result, err = service.RegisterNodeWithDiscovery(payload)
|
||||||
}
|
}
|
||||||
result, err := service.RegisterNode(discoveryNode.(*model.Node), payload)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
|
|||||||
@@ -32,6 +32,38 @@ func CreateNode(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetNodeBootstrapToken(c *gin.Context) {
|
||||||
|
bootstrap, err := service.GetNodeBootstrapView()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "",
|
||||||
|
"data": bootstrap,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func RotateNodeBootstrapToken(c *gin.Context) {
|
||||||
|
bootstrap, err := service.RotateGlobalDiscoveryToken()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "",
|
||||||
|
"data": bootstrap,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func UpdateNode(c *gin.Context) {
|
func UpdateNode(c *gin.Context) {
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
if err != nil || id == 0 {
|
if err != nil || id == 0 {
|
||||||
|
|||||||
@@ -23,19 +23,23 @@ func AgentAuth() func(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func AgentDiscoveryAuth() func(c *gin.Context) {
|
func AgentRegisterAuth() func(c *gin.Context) {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
token := c.GetHeader("X-Agent-Token")
|
token := c.GetHeader("X-Agent-Token")
|
||||||
node, err := service.AuthenticateDiscoveryToken(token)
|
if node, err := service.AuthenticateAgentToken(token); err == nil {
|
||||||
if err != nil {
|
c.Set("agent_node", node)
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := service.ValidateDiscoveryToken(token); err != nil {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"message": "无权进行此操作,Discovery Token 无效",
|
"message": "无权进行此操作,注册 Token 无效",
|
||||||
})
|
})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Set("discovery_node", node)
|
c.Set("discovery_enabled", true)
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ type Node struct {
|
|||||||
Name string `json:"name" gorm:"size:128;not null"`
|
Name string `json:"name" gorm:"size:128;not null"`
|
||||||
IP string `json:"ip" gorm:"size:64;not null"`
|
IP string `json:"ip" gorm:"size:64;not null"`
|
||||||
AgentToken string `json:"-" gorm:"size:128;index"`
|
AgentToken string `json:"-" gorm:"size:128;index"`
|
||||||
DiscoveryToken string `json:"-" gorm:"size:128;index"`
|
|
||||||
AgentVersion string `json:"agent_version" gorm:"size:64;not null"`
|
AgentVersion string `json:"agent_version" gorm:"size:64;not null"`
|
||||||
NginxVersion string `json:"nginx_version" gorm:"size:64"`
|
NginxVersion string `json:"nginx_version" gorm:"size:64"`
|
||||||
Status string `json:"status" gorm:"size:16;not null;default:'offline'"`
|
Status string `json:"status" gorm:"size:16;not null;default:'offline'"`
|
||||||
@@ -42,12 +41,6 @@ func GetNodeByAgentToken(token string) (*Node, error) {
|
|||||||
return node, err
|
return node, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetNodeByDiscoveryToken(token string) (*Node, error) {
|
|
||||||
node := &Node{}
|
|
||||||
err := DB.Where("discovery_token = ?", token).First(node).Error
|
|
||||||
return node, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (node *Node) Insert() error {
|
func (node *Node) Insert() error {
|
||||||
return DB.Create(node).Error
|
return DB.Create(node).Error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ func InitOptionMap() {
|
|||||||
common.OptionMap["WeChatAccountQRCodeImageURL"] = ""
|
common.OptionMap["WeChatAccountQRCodeImageURL"] = ""
|
||||||
common.OptionMap["TurnstileSiteKey"] = ""
|
common.OptionMap["TurnstileSiteKey"] = ""
|
||||||
common.OptionMap["TurnstileSecretKey"] = ""
|
common.OptionMap["TurnstileSecretKey"] = ""
|
||||||
|
common.OptionMap["AgentDiscoveryToken"] = ""
|
||||||
common.OptionMapRWMutex.Unlock()
|
common.OptionMapRWMutex.Unlock()
|
||||||
options, _ := AllOption()
|
options, _ := AllOption()
|
||||||
for _, option := range options {
|
for _, option := range options {
|
||||||
@@ -141,5 +142,7 @@ func updateOptionMap(key string, value string) {
|
|||||||
common.TurnstileSiteKey = value
|
common.TurnstileSiteKey = value
|
||||||
case "TurnstileSecretKey":
|
case "TurnstileSecretKey":
|
||||||
common.TurnstileSecretKey = value
|
common.TurnstileSecretKey = value
|
||||||
|
case "AgentDiscoveryToken":
|
||||||
|
common.AgentDiscoveryToken = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
nodeRoute := apiRouter.Group("/nodes")
|
nodeRoute := apiRouter.Group("/nodes")
|
||||||
nodeRoute.Use(middleware.AdminAuth())
|
nodeRoute.Use(middleware.AdminAuth())
|
||||||
{
|
{
|
||||||
|
nodeRoute.GET("/bootstrap-token", controller.GetNodeBootstrapToken)
|
||||||
|
nodeRoute.POST("/bootstrap-token/rotate", controller.RotateNodeBootstrapToken)
|
||||||
nodeRoute.GET("/", controller.GetNodes)
|
nodeRoute.GET("/", controller.GetNodes)
|
||||||
nodeRoute.POST("/", controller.CreateNode)
|
nodeRoute.POST("/", controller.CreateNode)
|
||||||
nodeRoute.PUT("/:id", controller.UpdateNode)
|
nodeRoute.PUT("/:id", controller.UpdateNode)
|
||||||
@@ -111,7 +113,7 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
agentRoute := apiRouter.Group("/agent")
|
agentRoute := apiRouter.Group("/agent")
|
||||||
{
|
{
|
||||||
discoveryRoute := agentRoute.Group("/")
|
discoveryRoute := agentRoute.Group("/")
|
||||||
discoveryRoute.Use(middleware.AgentDiscoveryAuth())
|
discoveryRoute.Use(middleware.AgentRegisterAuth())
|
||||||
{
|
{
|
||||||
discoveryRoute.POST("/nodes/register", controller.AgentRegister)
|
discoveryRoute.POST("/nodes/register", controller.AgentRegister)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,24 +41,8 @@ func TestPhase2AgentLifecycle(t *testing.T) {
|
|||||||
})
|
})
|
||||||
var createdNode service.NodeView
|
var createdNode service.NodeView
|
||||||
decodeResponseData(t, createdNodeResp, &createdNode)
|
decodeResponseData(t, createdNodeResp, &createdNode)
|
||||||
if createdNode.DiscoveryToken == "" || !createdNode.Pending {
|
if createdNode.AgentToken == "" || !createdNode.Pending {
|
||||||
t.Fatal("expected created node to expose discovery token while pending")
|
t.Fatal("expected created node to expose agent token while pending")
|
||||||
}
|
|
||||||
|
|
||||||
nodePayload := map[string]any{
|
|
||||||
"node_id": "local-node-id",
|
|
||||||
"name": "shanghai-edge-1",
|
|
||||||
"ip": "10.0.0.8",
|
|
||||||
"agent_version": "0.1.0",
|
|
||||||
"nginx_version": "1.25.5",
|
|
||||||
"current_version": "",
|
|
||||||
"last_error": "",
|
|
||||||
}
|
|
||||||
resp := performAgentJSONRequestWithToken(t, engine, createdNode.DiscoveryToken, http.MethodPost, "/api/agent/nodes/register", nodePayload)
|
|
||||||
var registration service.AgentRegistrationResponse
|
|
||||||
decodeResponseData(t, resp, ®istration)
|
|
||||||
if registration.NodeID != createdNode.NodeID || registration.AgentToken == "" {
|
|
||||||
t.Fatal("expected discovery registration to return assigned node_id and agent token")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
heartbeatPayload := map[string]any{
|
heartbeatPayload := map[string]any{
|
||||||
@@ -70,21 +54,21 @@ func TestPhase2AgentLifecycle(t *testing.T) {
|
|||||||
"current_version": "",
|
"current_version": "",
|
||||||
"last_error": "",
|
"last_error": "",
|
||||||
}
|
}
|
||||||
resp = performAgentJSONRequestWithToken(t, engine, registration.AgentToken, http.MethodPost, "/api/agent/nodes/heartbeat", heartbeatPayload)
|
resp := performAgentJSONRequestWithToken(t, engine, createdNode.AgentToken, http.MethodPost, "/api/agent/nodes/heartbeat", heartbeatPayload)
|
||||||
var registeredNode model.Node
|
var registeredNode model.Node
|
||||||
decodeResponseData(t, resp, ®isteredNode)
|
decodeResponseData(t, resp, ®isteredNode)
|
||||||
if registeredNode.IP != "10.0.0.9" || registeredNode.AgentVersion != "0.1.1" || registeredNode.NodeID != createdNode.NodeID {
|
if registeredNode.IP != "10.0.0.9" || registeredNode.AgentVersion != "0.1.1" || registeredNode.NodeID != createdNode.NodeID {
|
||||||
t.Fatal("expected heartbeat to update node metadata")
|
t.Fatal("expected heartbeat to update node metadata")
|
||||||
}
|
}
|
||||||
|
|
||||||
activeConfigResp := performAgentJSONRequestWithToken(t, engine, registration.AgentToken, http.MethodGet, "/api/agent/config-versions/active", nil)
|
activeConfigResp := performAgentJSONRequestWithToken(t, engine, createdNode.AgentToken, http.MethodGet, "/api/agent/config-versions/active", nil)
|
||||||
var activeConfig service.AgentConfigResponse
|
var activeConfig service.AgentConfigResponse
|
||||||
decodeResponseData(t, activeConfigResp, &activeConfig)
|
decodeResponseData(t, activeConfigResp, &activeConfig)
|
||||||
if activeConfig.Version == "" || activeConfig.RenderedConfig == "" || activeConfig.Checksum == "" {
|
if activeConfig.Version == "" || activeConfig.RenderedConfig == "" || activeConfig.Checksum == "" {
|
||||||
t.Fatal("expected active config response to contain version payload")
|
t.Fatal("expected active config response to contain version payload")
|
||||||
}
|
}
|
||||||
|
|
||||||
successApplyResp := performAgentJSONRequestWithToken(t, engine, registration.AgentToken, http.MethodPost, "/api/agent/apply-logs", map[string]any{
|
successApplyResp := performAgentJSONRequestWithToken(t, engine, createdNode.AgentToken, http.MethodPost, "/api/agent/apply-logs", map[string]any{
|
||||||
"node_id": "spoofed-node-id",
|
"node_id": "spoofed-node-id",
|
||||||
"version": activeConfig.Version,
|
"version": activeConfig.Version,
|
||||||
"result": service.ApplyResultOK,
|
"result": service.ApplyResultOK,
|
||||||
@@ -96,7 +80,7 @@ func TestPhase2AgentLifecycle(t *testing.T) {
|
|||||||
t.Fatal("expected apply log success to be recorded")
|
t.Fatal("expected apply log success to be recorded")
|
||||||
}
|
}
|
||||||
|
|
||||||
failedApplyResp := performAgentJSONRequestWithToken(t, engine, registration.AgentToken, http.MethodPost, "/api/agent/apply-logs", map[string]any{
|
failedApplyResp := performAgentJSONRequestWithToken(t, engine, createdNode.AgentToken, http.MethodPost, "/api/agent/apply-logs", map[string]any{
|
||||||
"node_id": "spoofed-node-id",
|
"node_id": "spoofed-node-id",
|
||||||
"version": activeConfig.Version,
|
"version": activeConfig.Version,
|
||||||
"result": service.ApplyResultFailed,
|
"result": service.ApplyResultFailed,
|
||||||
@@ -114,8 +98,11 @@ func TestPhase2AgentLifecycle(t *testing.T) {
|
|||||||
if len(nodes) != 1 {
|
if len(nodes) != 1 {
|
||||||
t.Fatalf("expected 1 node, got %d", len(nodes))
|
t.Fatalf("expected 1 node, got %d", len(nodes))
|
||||||
}
|
}
|
||||||
if nodes[0].Pending || nodes[0].DiscoveryToken != "" {
|
if nodes[0].Pending {
|
||||||
t.Fatal("expected registered node to clear pending discovery state")
|
t.Fatal("expected registered node to clear pending state")
|
||||||
|
}
|
||||||
|
if nodes[0].AgentToken != createdNode.AgentToken {
|
||||||
|
t.Fatal("expected node auth token to remain stable after occupancy")
|
||||||
}
|
}
|
||||||
if nodes[0].LatestApplyResult != service.ApplyResultFailed || nodes[0].LatestApplyMessage != "nginx reload failed" {
|
if nodes[0].LatestApplyResult != service.ApplyResultFailed || nodes[0].LatestApplyMessage != "nginx reload failed" {
|
||||||
t.Fatal("expected node list to expose latest apply status")
|
t.Fatal("expected node list to expose latest apply status")
|
||||||
@@ -159,7 +146,7 @@ func TestPhase2AgentLifecycle(t *testing.T) {
|
|||||||
|
|
||||||
deniedReq := httptest.NewRequest(http.MethodPost, "/api/agent/nodes/heartbeat", bytes.NewReader([]byte(`{"ip":"10.0.0.9","agent_version":"0.1.1"}`)))
|
deniedReq := httptest.NewRequest(http.MethodPost, "/api/agent/nodes/heartbeat", bytes.NewReader([]byte(`{"ip":"10.0.0.9","agent_version":"0.1.1"}`)))
|
||||||
deniedReq.Header.Set("Content-Type", "application/json")
|
deniedReq.Header.Set("Content-Type", "application/json")
|
||||||
deniedReq.Header.Set("X-Agent-Token", registration.AgentToken)
|
deniedReq.Header.Set("X-Agent-Token", createdNode.AgentToken)
|
||||||
deniedRecorder := httptest.NewRecorder()
|
deniedRecorder := httptest.NewRecorder()
|
||||||
engine.ServeHTTP(deniedRecorder, deniedReq)
|
engine.ServeHTTP(deniedRecorder, deniedReq)
|
||||||
if deniedRecorder.Code != http.StatusUnauthorized {
|
if deniedRecorder.Code != http.StatusUnauthorized {
|
||||||
@@ -167,6 +154,49 @@ func TestPhase2AgentLifecycle(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPhase2GlobalDiscoveryRegistration(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
common.RedisEnabled = false
|
||||||
|
setupTestDB(t)
|
||||||
|
|
||||||
|
engine := gin.New()
|
||||||
|
engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret"))))
|
||||||
|
router.SetApiRouter(engine)
|
||||||
|
|
||||||
|
adminToken := prepareRootToken(t)
|
||||||
|
bootstrapResp := performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/nodes/bootstrap-token", nil)
|
||||||
|
var bootstrap service.NodeBootstrapView
|
||||||
|
decodeResponseData(t, bootstrapResp, &bootstrap)
|
||||||
|
if bootstrap.DiscoveryToken == "" {
|
||||||
|
t.Fatal("expected global discovery token to be available")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := performAgentJSONRequestWithToken(t, engine, bootstrap.DiscoveryToken, http.MethodPost, "/api/agent/nodes/register", map[string]any{
|
||||||
|
"node_id": "local-node-id",
|
||||||
|
"name": "bulk-edge-1",
|
||||||
|
"ip": "10.0.0.18",
|
||||||
|
"agent_version": "0.2.0",
|
||||||
|
"nginx_version": "1.25.5",
|
||||||
|
"current_version": "",
|
||||||
|
"last_error": "",
|
||||||
|
})
|
||||||
|
var registration service.AgentRegistrationResponse
|
||||||
|
decodeResponseData(t, resp, ®istration)
|
||||||
|
if registration.AgentToken == "" || registration.NodeID == "" {
|
||||||
|
t.Fatal("expected discovery registration to issue node-specific agent token")
|
||||||
|
}
|
||||||
|
|
||||||
|
nodesResp := performJSONRequest(t, engine, adminToken, http.MethodGet, "/api/nodes/", nil)
|
||||||
|
var nodes []service.NodeView
|
||||||
|
decodeResponseData(t, nodesResp, &nodes)
|
||||||
|
if len(nodes) != 1 {
|
||||||
|
t.Fatalf("expected 1 discovered node, got %d", len(nodes))
|
||||||
|
}
|
||||||
|
if nodes[0].Name != "bulk-edge-1" || nodes[0].AgentToken != registration.AgentToken || nodes[0].Pending {
|
||||||
|
t.Fatal("expected discovered node to be created online with issued agent token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func performAgentJSONRequestWithToken(t *testing.T, engine http.Handler, token string, method string, path string, body any) apiResponse {
|
func performAgentJSONRequestWithToken(t *testing.T, engine http.Handler, token string, method string, path string, body any) apiResponse {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var payload []byte
|
var payload []byte
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ type NodeView struct {
|
|||||||
NodeID string `json:"node_id"`
|
NodeID string `json:"node_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
DiscoveryToken string `json:"discovery_token,omitempty"`
|
AgentToken string `json:"agent_token"`
|
||||||
Pending bool `json:"pending"`
|
Pending bool `json:"pending"`
|
||||||
AgentVersion string `json:"agent_version"`
|
AgentVersion string `json:"agent_version"`
|
||||||
NginxVersion string `json:"nginx_version"`
|
NginxVersion string `json:"nginx_version"`
|
||||||
@@ -65,54 +65,15 @@ type NodeView struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func RegisterNode(node *model.Node, payload AgentNodePayload) (*AgentRegistrationResponse, error) {
|
func RegisterNode(node *model.Node, payload AgentNodePayload) (*AgentRegistrationResponse, error) {
|
||||||
common.SysLog("agent discovery register request received: name=" + strings.TrimSpace(payload.Name) + " ip=" + strings.TrimSpace(payload.IP))
|
return RegisterNodeWithAgentToken(node, payload)
|
||||||
payload.Name = strings.TrimSpace(payload.Name)
|
|
||||||
payload.IP = strings.TrimSpace(payload.IP)
|
|
||||||
payload.AgentVersion = strings.TrimSpace(payload.AgentVersion)
|
|
||||||
payload.NginxVersion = strings.TrimSpace(payload.NginxVersion)
|
|
||||||
payload.CurrentVersion = strings.TrimSpace(payload.CurrentVersion)
|
|
||||||
payload.LastError = strings.TrimSpace(payload.LastError)
|
|
||||||
if node == nil {
|
|
||||||
return nil, errors.New("节点不存在")
|
|
||||||
}
|
|
||||||
if payload.IP == "" {
|
|
||||||
return nil, errors.New("ip 不能为空")
|
|
||||||
}
|
|
||||||
if payload.AgentVersion == "" {
|
|
||||||
return nil, errors.New("agent_version 不能为空")
|
|
||||||
}
|
|
||||||
agentToken, err := newRandomToken()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
applyNodeRuntime(node, payload, true)
|
|
||||||
node.AgentToken = agentToken
|
|
||||||
node.DiscoveryToken = ""
|
|
||||||
if err = node.Update(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
common.SysLog("agent discovery register succeeded: node_id=" + node.NodeID + " name=" + node.Name)
|
|
||||||
return &AgentRegistrationResponse{
|
|
||||||
NodeID: node.NodeID,
|
|
||||||
AgentToken: node.AgentToken,
|
|
||||||
Name: node.Name,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func HeartbeatNode(node *model.Node, payload AgentNodePayload) (*model.Node, error) {
|
func HeartbeatNode(node *model.Node, payload AgentNodePayload) (*model.Node, error) {
|
||||||
common.SysLog("agent heartbeat received: node_id=" + node.NodeID + " current_version=" + strings.TrimSpace(payload.CurrentVersion))
|
common.SysLog("agent heartbeat received: node_id=" + node.NodeID + " current_version=" + strings.TrimSpace(payload.CurrentVersion))
|
||||||
payload.NodeID = node.NodeID
|
payload.NodeID = node.NodeID
|
||||||
payload.Name = strings.TrimSpace(payload.Name)
|
payload = normalizeAgentNodePayload(payload)
|
||||||
payload.IP = strings.TrimSpace(payload.IP)
|
if err := validateAgentNodePayload(payload); err != nil {
|
||||||
payload.AgentVersion = strings.TrimSpace(payload.AgentVersion)
|
return nil, err
|
||||||
payload.NginxVersion = strings.TrimSpace(payload.NginxVersion)
|
|
||||||
payload.CurrentVersion = strings.TrimSpace(payload.CurrentVersion)
|
|
||||||
payload.LastError = strings.TrimSpace(payload.LastError)
|
|
||||||
if payload.IP == "" {
|
|
||||||
return nil, errors.New("ip 不能为空")
|
|
||||||
}
|
|
||||||
if payload.AgentVersion == "" {
|
|
||||||
return nil, errors.New("agent_version 不能为空")
|
|
||||||
}
|
}
|
||||||
applyNodeRuntime(node, payload, true)
|
applyNodeRuntime(node, payload, true)
|
||||||
if err := model.DB.Model(node).Select("ip", "agent_version", "nginx_version", "status", "current_version", "last_seen_at", "last_error").Updates(node).Error; err != nil {
|
if err := model.DB.Model(node).Select("ip", "agent_version", "nginx_version", "status", "current_version", "last_seen_at", "last_error").Updates(node).Error; err != nil {
|
||||||
@@ -237,11 +198,8 @@ func computeNodeStatus(node *model.Node) string {
|
|||||||
if node == nil {
|
if node == nil {
|
||||||
return NodeStatusOffline
|
return NodeStatusOffline
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(node.AgentToken) == "" && strings.TrimSpace(node.DiscoveryToken) != "" {
|
|
||||||
return NodeStatusPending
|
|
||||||
}
|
|
||||||
if node.LastSeenAt.IsZero() {
|
if node.LastSeenAt.IsZero() {
|
||||||
return NodeStatusOffline
|
return NodeStatusPending
|
||||||
}
|
}
|
||||||
if time.Since(node.LastSeenAt) > common.NodeOfflineThreshold {
|
if time.Since(node.LastSeenAt) > common.NodeOfflineThreshold {
|
||||||
return NodeStatusOffline
|
return NodeStatusOffline
|
||||||
|
|||||||
+135
-7
@@ -14,6 +14,10 @@ type NodeInput struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type NodeBootstrapView struct {
|
||||||
|
DiscoveryToken string `json:"discovery_token"`
|
||||||
|
}
|
||||||
|
|
||||||
type AgentRegistrationResponse struct {
|
type AgentRegistrationResponse struct {
|
||||||
NodeID string `json:"node_id"`
|
NodeID string `json:"node_id"`
|
||||||
AgentToken string `json:"agent_token"`
|
AgentToken string `json:"agent_token"`
|
||||||
@@ -37,7 +41,7 @@ func CreateNode(input NodeInput) (*NodeView, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
node.DiscoveryToken, err = newRandomToken()
|
node.AgentToken, err = newRandomToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -85,12 +89,61 @@ func AuthenticateAgentToken(token string) (*model.Node, error) {
|
|||||||
return model.GetNodeByAgentToken(token)
|
return model.GetNodeByAgentToken(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
func AuthenticateDiscoveryToken(token string) (*model.Node, error) {
|
func ValidateDiscoveryToken(token string) error {
|
||||||
token = strings.TrimSpace(token)
|
token = strings.TrimSpace(token)
|
||||||
if token == "" {
|
if token == "" {
|
||||||
return nil, errors.New("缺少 Discovery Token")
|
return errors.New("缺少 Discovery Token")
|
||||||
}
|
}
|
||||||
return model.GetNodeByDiscoveryToken(token)
|
discoveryToken, err := EnsureGlobalDiscoveryToken()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if token != discoveryToken {
|
||||||
|
return errors.New("Discovery Token 无效")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func EnsureGlobalDiscoveryToken() (string, error) {
|
||||||
|
common.OptionMapRWMutex.RLock()
|
||||||
|
needsInit := common.OptionMap == nil
|
||||||
|
common.OptionMapRWMutex.RUnlock()
|
||||||
|
if needsInit {
|
||||||
|
model.InitOptionMap()
|
||||||
|
}
|
||||||
|
common.OptionMapRWMutex.RLock()
|
||||||
|
token := strings.TrimSpace(common.OptionMap["AgentDiscoveryToken"])
|
||||||
|
common.OptionMapRWMutex.RUnlock()
|
||||||
|
if token != "" {
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
token, err := newRandomToken()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err = model.UpdateOption("AgentDiscoveryToken", token); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetNodeBootstrapView() (*NodeBootstrapView, error) {
|
||||||
|
token, err := EnsureGlobalDiscoveryToken()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &NodeBootstrapView{DiscoveryToken: token}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func RotateGlobalDiscoveryToken() (*NodeBootstrapView, error) {
|
||||||
|
token, err := newRandomToken()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err = model.UpdateOption("AgentDiscoveryToken", token); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &NodeBootstrapView{DiscoveryToken: token}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildNodeView(node *model.Node) *NodeView {
|
func buildNodeView(node *model.Node) *NodeView {
|
||||||
@@ -100,6 +153,7 @@ func buildNodeView(node *model.Node) *NodeView {
|
|||||||
NodeID: node.NodeID,
|
NodeID: node.NodeID,
|
||||||
Name: node.Name,
|
Name: node.Name,
|
||||||
IP: node.IP,
|
IP: node.IP,
|
||||||
|
AgentToken: node.AgentToken,
|
||||||
AgentVersion: node.AgentVersion,
|
AgentVersion: node.AgentVersion,
|
||||||
NginxVersion: node.NginxVersion,
|
NginxVersion: node.NginxVersion,
|
||||||
Status: status,
|
Status: status,
|
||||||
@@ -110,12 +164,86 @@ func buildNodeView(node *model.Node) *NodeView {
|
|||||||
UpdatedAt: node.UpdatedAt,
|
UpdatedAt: node.UpdatedAt,
|
||||||
Pending: status == NodeStatusPending,
|
Pending: status == NodeStatusPending,
|
||||||
}
|
}
|
||||||
if status == NodeStatusPending {
|
|
||||||
view.DiscoveryToken = node.DiscoveryToken
|
|
||||||
}
|
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func RegisterNodeWithAgentToken(node *model.Node, payload AgentNodePayload) (*AgentRegistrationResponse, error) {
|
||||||
|
payload = normalizeAgentNodePayload(payload)
|
||||||
|
if node == nil {
|
||||||
|
return nil, errors.New("节点不存在")
|
||||||
|
}
|
||||||
|
if err := validateAgentNodePayload(payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
applyNodeRuntime(node, payload, true)
|
||||||
|
if err := node.Update(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
common.SysLog("agent register succeeded on reserved node: node_id=" + node.NodeID + " name=" + node.Name)
|
||||||
|
return &AgentRegistrationResponse{
|
||||||
|
NodeID: node.NodeID,
|
||||||
|
AgentToken: node.AgentToken,
|
||||||
|
Name: node.Name,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterNodeWithDiscovery(payload AgentNodePayload) (*AgentRegistrationResponse, error) {
|
||||||
|
payload = normalizeAgentNodePayload(payload)
|
||||||
|
if err := validateAgentNodePayload(payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
nodeID, err := newServerNodeID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
agentToken, err := newRandomToken()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
nodeName := payload.Name
|
||||||
|
if nodeName == "" {
|
||||||
|
nodeName = nodeID
|
||||||
|
}
|
||||||
|
node := &model.Node{
|
||||||
|
NodeID: nodeID,
|
||||||
|
Name: nodeName,
|
||||||
|
AgentToken: agentToken,
|
||||||
|
}
|
||||||
|
applyNodeRuntime(node, payload, false)
|
||||||
|
if err = node.Insert(); err != nil {
|
||||||
|
if isUniqueConstraintError(err) {
|
||||||
|
return nil, errors.New("节点标识生成冲突,请重试")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
common.SysLog("agent discovery register succeeded: node_id=" + node.NodeID + " name=" + node.Name)
|
||||||
|
return &AgentRegistrationResponse{
|
||||||
|
NodeID: node.NodeID,
|
||||||
|
AgentToken: node.AgentToken,
|
||||||
|
Name: node.Name,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAgentNodePayload(payload AgentNodePayload) AgentNodePayload {
|
||||||
|
payload.Name = strings.TrimSpace(payload.Name)
|
||||||
|
payload.IP = strings.TrimSpace(payload.IP)
|
||||||
|
payload.AgentVersion = strings.TrimSpace(payload.AgentVersion)
|
||||||
|
payload.NginxVersion = strings.TrimSpace(payload.NginxVersion)
|
||||||
|
payload.CurrentVersion = strings.TrimSpace(payload.CurrentVersion)
|
||||||
|
payload.LastError = strings.TrimSpace(payload.LastError)
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateAgentNodePayload(payload AgentNodePayload) error {
|
||||||
|
if payload.IP == "" {
|
||||||
|
return errors.New("ip 不能为空")
|
||||||
|
}
|
||||||
|
if payload.AgentVersion == "" {
|
||||||
|
return errors.New("agent_version 不能为空")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func applyNodeRuntime(node *model.Node, payload AgentNodePayload, preserveName bool) {
|
func applyNodeRuntime(node *model.Node, payload AgentNodePayload, preserveName bool) {
|
||||||
if !preserveName || strings.TrimSpace(node.Name) == "" {
|
if !preserveName || strings.TrimSpace(node.Name) == "" {
|
||||||
if strings.TrimSpace(payload.Name) != "" {
|
if strings.TrimSpace(payload.Name) != "" {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const renderApply = (result) => {
|
|||||||
|
|
||||||
const Node = () => {
|
const Node = () => {
|
||||||
const [nodes, setNodes] = useState([]);
|
const [nodes, setNodes] = useState([]);
|
||||||
|
const [bootstrap, setBootstrap] = useState({ discovery_token: '' });
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [form, setForm] = useState(initialForm);
|
const [form, setForm] = useState(initialForm);
|
||||||
@@ -45,7 +46,31 @@ const Node = () => {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadBootstrapToken = async () => {
|
||||||
|
const res = await API.get('/api/nodes/bootstrap-token');
|
||||||
|
const { success, message, data } = res.data;
|
||||||
|
if (success) {
|
||||||
|
setBootstrap(data || { discovery_token: '' });
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rotateBootstrapToken = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await API.post('/api/nodes/bootstrap-token/rotate');
|
||||||
|
const { success, message, data } = res.data;
|
||||||
|
if (success) {
|
||||||
|
setBootstrap(data || { discovery_token: '' });
|
||||||
|
showSuccess('全局 discovery token 已重新生成');
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
loadBootstrapToken().then();
|
||||||
loadNodes().then();
|
loadNodes().then();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -100,7 +125,20 @@ const Node = () => {
|
|||||||
return (
|
return (
|
||||||
<Segment loading={loading}>
|
<Segment loading={loading}>
|
||||||
<Header as='h3'>节点管理</Header>
|
<Header as='h3'>节点管理</Header>
|
||||||
<p className='page-subtitle'>创建节点、查看接入令牌,并跟踪节点在线状态、版本与最近一次应用结果。</p>
|
<p className='page-subtitle'>创建节点后会直接生成节点专属 auth token;批量部署时可复用全局 discovery token 自动注册。</p>
|
||||||
|
|
||||||
|
<Form>
|
||||||
|
<Form.Group widths='equal'>
|
||||||
|
<Form.Input
|
||||||
|
label='全局 Discovery Token'
|
||||||
|
readOnly
|
||||||
|
value={bootstrap.discovery_token || ''}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
<Button type='button' onClick={rotateBootstrapToken}>
|
||||||
|
重新生成 Discovery Token
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
|
||||||
<Form onSubmit={submitNode}>
|
<Form onSubmit={submitNode}>
|
||||||
<Form.Group widths='equal'>
|
<Form.Group widths='equal'>
|
||||||
@@ -126,7 +164,7 @@ const Node = () => {
|
|||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.HeaderCell>节点名</Table.HeaderCell>
|
<Table.HeaderCell>节点名</Table.HeaderCell>
|
||||||
<Table.HeaderCell>Node ID</Table.HeaderCell>
|
<Table.HeaderCell>Node ID</Table.HeaderCell>
|
||||||
<Table.HeaderCell>Discovery Token</Table.HeaderCell>
|
<Table.HeaderCell>Auth Token</Table.HeaderCell>
|
||||||
<Table.HeaderCell>IP</Table.HeaderCell>
|
<Table.HeaderCell>IP</Table.HeaderCell>
|
||||||
<Table.HeaderCell>状态</Table.HeaderCell>
|
<Table.HeaderCell>状态</Table.HeaderCell>
|
||||||
<Table.HeaderCell>Agent / Nginx</Table.HeaderCell>
|
<Table.HeaderCell>Agent / Nginx</Table.HeaderCell>
|
||||||
@@ -143,12 +181,12 @@ const Node = () => {
|
|||||||
<Table.Cell>{node.name}</Table.Cell>
|
<Table.Cell>{node.name}</Table.Cell>
|
||||||
<Table.Cell>{node.node_id}</Table.Cell>
|
<Table.Cell>{node.node_id}</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
{node.pending && node.discovery_token ? (
|
{node.agent_token ? (
|
||||||
<>
|
<>
|
||||||
<Label color='orange'>待接入</Label>
|
{node.pending ? <Label color='orange'>未占用</Label> : <Label color='green'>已绑定</Label>}
|
||||||
<div className='table-meta' style={{ wordBreak: 'break-all' }}>{node.discovery_token}</div>
|
<div className='table-meta' style={{ wordBreak: 'break-all' }}>{node.agent_token}</div>
|
||||||
</>
|
</>
|
||||||
) : '已激活'}
|
) : '暂无'}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>{node.ip}</Table.Cell>
|
<Table.Cell>{node.ip}</Table.Cell>
|
||||||
<Table.Cell>{renderStatus(node.status)}</Table.Cell>
|
<Table.Cell>{renderStatus(node.status)}</Table.Cell>
|
||||||
|
|||||||
+9
-7
@@ -60,9 +60,10 @@
|
|||||||
**2.5.3 Agent 管理与自动发现**
|
**2.5.3 Agent 管理与自动发现**
|
||||||
|
|
||||||
* 管理端支持手工创建节点、编辑节点名、删除节点
|
* 管理端支持手工创建节点、编辑节点名、删除节点
|
||||||
* `nodes` 表增加 Agent 鉴权 Token 与自动发现 Token 字段
|
* 管理端手工创建节点时,直接为该节点生成专属 `agent_token`
|
||||||
* 首次接入不再依赖全局环境变量 Token,而是依赖管理端为节点生成的自动发现 Token
|
* 预创建节点时,持有该 `agent_token` 的 Agent 会占据该节点位,并持续以该 Token 完成后续鉴权
|
||||||
* Agent 首次注册成功后,Server 下发节点专属 Agent Token,Agent 本地完成 Token 置换
|
* 系统同时维护一个全局 `discovery_token`,任意新节点可使用该 Token 自动接入 Server
|
||||||
|
* Agent 使用全局 `discovery_token` 首次注册成功后,Server 会为该节点生成专属 `agent_token`,Agent 本地完成 Token 置换
|
||||||
* Agent 默认自动探测主机名与 IP,也允许通过配置覆盖
|
* Agent 默认自动探测主机名与 IP,也允许通过配置覆盖
|
||||||
|
|
||||||
**2.5.4 路由增强**
|
**2.5.4 路由增强**
|
||||||
@@ -291,13 +292,14 @@ Agent 使用 Go 单体程序:
|
|||||||
|
|
||||||
新增字段建议:
|
新增字段建议:
|
||||||
|
|
||||||
* `agent_token` — 节点专属 Agent Token,用于注册完成后的正式鉴权
|
* `agent_token` — 节点专属 Agent Token,用于节点占位与后续正式鉴权
|
||||||
* `discovery_token` — 自动发现 Token,仅用于首次接入
|
|
||||||
|
|
||||||
约束:
|
约束:
|
||||||
|
|
||||||
* `agent_token` 与 `discovery_token` 都应为随机生成值
|
* 管理端手工创建节点时必须直接生成 `agent_token`
|
||||||
* `discovery_token` 仅用于首次接入,注册成功后应失效或清空
|
* 一个节点位只对应一个 `agent_token`
|
||||||
|
* 全局 `discovery_token` 不存放在 `nodes` 表,而由系统配置统一维护
|
||||||
|
* 使用全局 `discovery_token` 自动接入的节点,应在注册成功后获得新的专属 `agent_token`
|
||||||
* 删除节点后,该节点关联的 Token 必须立即失效
|
* 删除节点后,该节点关联的 Token 必须立即失效
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ Agent 放在 `atsf_agent`,使用 Go 单体程序开发。
|
|||||||
|
|
||||||
第二版扩展实体:
|
第二版扩展实体:
|
||||||
|
|
||||||
* `nodes` — 增加 `agent_token`、`discovery_token`,用于节点管理与自动发现
|
* `nodes` — 增加 `agent_token`,用于节点占位与节点鉴权
|
||||||
|
|
||||||
约束(全版本):
|
约束(全版本):
|
||||||
|
|
||||||
@@ -154,7 +154,8 @@ Agent 放在 `atsf_agent`,使用 Go 单体程序开发。
|
|||||||
* 激活版本全局只能有一个,不引入分组维度
|
* 激活版本全局只能有一个,不引入分组维度
|
||||||
* 回滚通过"激活旧版本"实现,不直接修改历史记录
|
* 回滚通过"激活旧版本"实现,不直接修改历史记录
|
||||||
* 域名到证书匹配必须支持精确匹配和通配符匹配(如 `*.example.com`)
|
* 域名到证书匹配必须支持精确匹配和通配符匹配(如 `*.example.com`)
|
||||||
* `nodes.discovery_token` 仅用于首次接入,接入成功后必须失效
|
* 管理端手工创建节点时必须直接生成 `nodes.agent_token`
|
||||||
|
* 全局 `discovery_token` 由系统配置统一保存,不按节点分配
|
||||||
* 删除节点必须立即使该节点凭证失效
|
* 删除节点必须立即使该节点凭证失效
|
||||||
|
|
||||||
如需新增表,必须先证明它服务于当前迭代版本的主链路。
|
如需新增表,必须先证明它服务于当前迭代版本的主链路。
|
||||||
@@ -214,9 +215,10 @@ Agent(第一版):
|
|||||||
|
|
||||||
Agent(第二版):
|
Agent(第二版):
|
||||||
|
|
||||||
* 首次接入使用 `discovery_token`
|
* 手工创建的节点直接使用节点专属 `agent_token` 接入
|
||||||
* 注册成功后下发节点专属 `agent_token`
|
* 全局 `discovery_token` 仅用于批量自动发现
|
||||||
* 后续请求改为查 `nodes.agent_token` 验证
|
* 使用全局 `discovery_token` 注册成功后,下发节点专属 `agent_token`
|
||||||
|
* 后续请求统一查 `nodes.agent_token` 验证
|
||||||
* 不再依赖全局环境变量 Agent Token
|
* 不再依赖全局环境变量 Agent Token
|
||||||
|
|
||||||
注意:
|
注意:
|
||||||
@@ -303,6 +305,7 @@ Agent 必须满足以下行为:
|
|||||||
* 启动时先校验本地路由文件 checksum 与控制面激活版本是否一致
|
* 启动时先校验本地路由文件 checksum 与控制面激活版本是否一致
|
||||||
* Docker 模式启动时应重建容器,而不是继续复用异常停止的旧容器
|
* Docker 模式启动时应重建容器,而不是继续复用异常停止的旧容器
|
||||||
* 若本地 `agent_token` 为空且配置了 `discovery_token`,则应自动发起首次注册并完成 Token 置换
|
* 若本地 `agent_token` 为空且配置了 `discovery_token`,则应自动发起首次注册并完成 Token 置换
|
||||||
|
* 若本地已显式配置节点专属 `agent_token`,则无需注册,直接进入心跳与同步流程
|
||||||
|
|
||||||
### 7.3 容错规范
|
### 7.3 容错规范
|
||||||
|
|
||||||
|
|||||||
@@ -123,17 +123,19 @@ MVP 已于第一版完成。当前进入第二版迭代。
|
|||||||
|
|
||||||
* agent-auth 中间件改造(查表验证)
|
* agent-auth 中间件改造(查表验证)
|
||||||
* 移除全局 Token 环境变量, 节点不再通过该方式连接server
|
* 移除全局 Token 环境变量, 节点不再通过该方式连接server
|
||||||
* 引入自动发现TOKEN, 需要用户手动在节点页创建, 持有该TOKEN的节点会自动连接到SERVER
|
* 用户手动创建节点时,直接生成节点专属 auth token
|
||||||
|
* 引入全局自动发现 TOKEN,任意新节点持有同一个 TOKEN 即可自动连接到 SERVER
|
||||||
* Node CRUD API
|
* Node CRUD API
|
||||||
* 前端 节点 管理页
|
* 前端 节点 管理页
|
||||||
|
|
||||||
完成标准:
|
完成标准:
|
||||||
|
|
||||||
* 用户启动server后需要手动在节点页添加节点, 此时会生成随机TOKEN, 持有该TOKEN的节点可以加入server. server可以修改节点的名称
|
* 用户启动 server 后手动在节点页添加节点,会直接生成该节点的专属 auth token,持有该 token 的节点可占据该节点位
|
||||||
|
* 用户可在管理界面查看全局 discovery token,批量部署的节点可共用该 token 自动注册到 server
|
||||||
* 用户可以编辑节点, 包括节点名
|
* 用户可以编辑节点, 包括节点名
|
||||||
* 删除节点后 Agent 请求立即返回 401
|
* 删除节点后 Agent 请求立即返回 401
|
||||||
* 节点配置文件不再要求填写节点名和IP地址, 节点名默认从主机名获取, IP也是自动获取. 手动指定则为覆盖
|
* 节点配置文件不再要求填写节点名和IP地址, 节点名默认从主机名获取, IP也是自动获取. 手动指定则为覆盖
|
||||||
* 节点配置文件agent_token为空, 自动发现TOKEN配置为server生成值后, 会自己向server注册, 注册后会进行TOKEN置换, 更新节点配置文件agent_token
|
* 节点配置文件填写节点专属 `agent_token` 时,可直接上线;若 `agent_token` 为空且填写全局 discovery token,则会自动注册并完成 token 置换
|
||||||
|
|
||||||
|
|
||||||
### V2 Phase 4: 路由自定义头
|
### V2 Phase 4: 路由自定义头
|
||||||
@@ -208,8 +210,9 @@ MVP 已于第一版完成。当前进入第二版迭代。
|
|||||||
|
|
||||||
### V2 Phase 3 检查项
|
### V2 Phase 3 检查项
|
||||||
|
|
||||||
* 可通过管理界面创建节点并生成 discovery token
|
* 可通过管理界面创建节点并生成节点专属 auth token
|
||||||
* Agent 可使用 discovery token 自动接入并完成 agent token 置换
|
* 可查看全局 discovery token,并允许多个节点共用该 token 自动接入
|
||||||
|
* Agent 可使用全局 discovery token 自动接入并完成 agent token 置换
|
||||||
* 用户可编辑节点名并删除节点
|
* 用户可编辑节点名并删除节点
|
||||||
* 删除节点后 Agent 请求立即失败
|
* 删除节点后 Agent 请求立即失败
|
||||||
* Agent 在未显式配置节点名/IP 时可自动探测
|
* Agent 在未显式配置节点名/IP 时可自动探测
|
||||||
|
|||||||
Reference in New Issue
Block a user