diff --git a/atsf_server/common/constants.go b/atsf_server/common/constants.go index 9dfb4273..8d70c2e2 100644 --- a/atsf_server/common/constants.go +++ b/atsf_server/common/constants.go @@ -47,6 +47,7 @@ var WeChatAccountQRCodeImageURL = "" var TurnstileSiteKey = "" var TurnstileSecretKey = "" var AgentToken = "" +var AgentDiscoveryToken = "" var NodeOfflineThreshold = 2 * time.Minute const ( diff --git a/atsf_server/controller/agent.go b/atsf_server/controller/agent.go index d1d685cf..e446cf1f 100644 --- a/atsf_server/controller/agent.go +++ b/atsf_server/controller/agent.go @@ -17,15 +17,15 @@ func AgentRegister(c *gin.Context) { }) return } - discoveryNode, ok := c.Get("discovery_node") - if !ok { - c.JSON(http.StatusUnauthorized, gin.H{ - "success": false, - "message": "无权进行此操作,Discovery Token 无效", - }) - return + var ( + result *service.AgentRegistrationResponse + err error + ) + if authNode, ok := c.Get("agent_node"); ok { + result, err = service.RegisterNodeWithAgentToken(authNode.(*model.Node), payload) + } else { + result, err = service.RegisterNodeWithDiscovery(payload) } - result, err := service.RegisterNode(discoveryNode.(*model.Node), payload) if err != nil { c.JSON(http.StatusOK, gin.H{ "success": false, diff --git a/atsf_server/controller/node.go b/atsf_server/controller/node.go index a8dbcc5f..cb48adb5 100644 --- a/atsf_server/controller/node.go +++ b/atsf_server/controller/node.go @@ -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) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil || id == 0 { diff --git a/atsf_server/middleware/agent-auth.go b/atsf_server/middleware/agent-auth.go index 416f853a..5920a40b 100644 --- a/atsf_server/middleware/agent-auth.go +++ b/atsf_server/middleware/agent-auth.go @@ -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) { token := c.GetHeader("X-Agent-Token") - node, err := service.AuthenticateDiscoveryToken(token) - if err != nil { + if node, err := service.AuthenticateAgentToken(token); err == nil { + c.Set("agent_node", node) + c.Next() + return + } + if err := service.ValidateDiscoveryToken(token); err != nil { c.JSON(http.StatusUnauthorized, gin.H{ "success": false, - "message": "无权进行此操作,Discovery Token 无效", + "message": "无权进行此操作,注册 Token 无效", }) c.Abort() return } - c.Set("discovery_node", node) + c.Set("discovery_enabled", true) c.Next() } } diff --git a/atsf_server/model/node.go b/atsf_server/model/node.go index 43f1d22c..b63a7e2b 100644 --- a/atsf_server/model/node.go +++ b/atsf_server/model/node.go @@ -8,7 +8,6 @@ type Node struct { Name string `json:"name" gorm:"size:128;not null"` IP string `json:"ip" gorm:"size:64;not null"` AgentToken string `json:"-" gorm:"size:128;index"` - DiscoveryToken string `json:"-" gorm:"size:128;index"` AgentVersion string `json:"agent_version" gorm:"size:64;not null"` NginxVersion string `json:"nginx_version" gorm:"size:64"` Status string `json:"status" gorm:"size:16;not null;default:'offline'"` @@ -42,12 +41,6 @@ func GetNodeByAgentToken(token string) (*Node, error) { 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 { return DB.Create(node).Error } diff --git a/atsf_server/model/option.go b/atsf_server/model/option.go index 2528c7e4..8a89963e 100644 --- a/atsf_server/model/option.go +++ b/atsf_server/model/option.go @@ -49,6 +49,7 @@ func InitOptionMap() { common.OptionMap["WeChatAccountQRCodeImageURL"] = "" common.OptionMap["TurnstileSiteKey"] = "" common.OptionMap["TurnstileSecretKey"] = "" + common.OptionMap["AgentDiscoveryToken"] = "" common.OptionMapRWMutex.Unlock() options, _ := AllOption() for _, option := range options { @@ -141,5 +142,7 @@ func updateOptionMap(key string, value string) { common.TurnstileSiteKey = value case "TurnstileSecretKey": common.TurnstileSecretKey = value + case "AgentDiscoveryToken": + common.AgentDiscoveryToken = value } } diff --git a/atsf_server/router/api-router.go b/atsf_server/router/api-router.go index 8c2c4263..6b060d3e 100644 --- a/atsf_server/router/api-router.go +++ b/atsf_server/router/api-router.go @@ -98,6 +98,8 @@ func SetApiRouter(router *gin.Engine) { nodeRoute := apiRouter.Group("/nodes") nodeRoute.Use(middleware.AdminAuth()) { + nodeRoute.GET("/bootstrap-token", controller.GetNodeBootstrapToken) + nodeRoute.POST("/bootstrap-token/rotate", controller.RotateNodeBootstrapToken) nodeRoute.GET("/", controller.GetNodes) nodeRoute.POST("/", controller.CreateNode) nodeRoute.PUT("/:id", controller.UpdateNode) @@ -111,7 +113,7 @@ func SetApiRouter(router *gin.Engine) { agentRoute := apiRouter.Group("/agent") { discoveryRoute := agentRoute.Group("/") - discoveryRoute.Use(middleware.AgentDiscoveryAuth()) + discoveryRoute.Use(middleware.AgentRegisterAuth()) { discoveryRoute.POST("/nodes/register", controller.AgentRegister) } diff --git a/atsf_server/router/api_phase2_test.go b/atsf_server/router/api_phase2_test.go index f2657b62..9afbd237 100644 --- a/atsf_server/router/api_phase2_test.go +++ b/atsf_server/router/api_phase2_test.go @@ -41,24 +41,8 @@ func TestPhase2AgentLifecycle(t *testing.T) { }) var createdNode service.NodeView decodeResponseData(t, createdNodeResp, &createdNode) - if createdNode.DiscoveryToken == "" || !createdNode.Pending { - t.Fatal("expected created node to expose discovery 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") + if createdNode.AgentToken == "" || !createdNode.Pending { + t.Fatal("expected created node to expose agent token while pending") } heartbeatPayload := map[string]any{ @@ -70,21 +54,21 @@ func TestPhase2AgentLifecycle(t *testing.T) { "current_version": "", "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 decodeResponseData(t, resp, ®isteredNode) if registeredNode.IP != "10.0.0.9" || registeredNode.AgentVersion != "0.1.1" || registeredNode.NodeID != createdNode.NodeID { 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 decodeResponseData(t, activeConfigResp, &activeConfig) if activeConfig.Version == "" || activeConfig.RenderedConfig == "" || activeConfig.Checksum == "" { 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", "version": activeConfig.Version, "result": service.ApplyResultOK, @@ -96,7 +80,7 @@ func TestPhase2AgentLifecycle(t *testing.T) { 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", "version": activeConfig.Version, "result": service.ApplyResultFailed, @@ -114,8 +98,11 @@ func TestPhase2AgentLifecycle(t *testing.T) { if len(nodes) != 1 { t.Fatalf("expected 1 node, got %d", len(nodes)) } - if nodes[0].Pending || nodes[0].DiscoveryToken != "" { - t.Fatal("expected registered node to clear pending discovery state") + if nodes[0].Pending { + 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" { 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.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() engine.ServeHTTP(deniedRecorder, deniedReq) 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 { t.Helper() var payload []byte diff --git a/atsf_server/service/agent.go b/atsf_server/service/agent.go index ea4dc3af..3606c2a6 100644 --- a/atsf_server/service/agent.go +++ b/atsf_server/service/agent.go @@ -49,7 +49,7 @@ type NodeView struct { NodeID string `json:"node_id"` Name string `json:"name"` IP string `json:"ip"` - DiscoveryToken string `json:"discovery_token,omitempty"` + AgentToken string `json:"agent_token"` Pending bool `json:"pending"` AgentVersion string `json:"agent_version"` NginxVersion string `json:"nginx_version"` @@ -65,54 +65,15 @@ type NodeView struct { } 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)) - 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 + return RegisterNodeWithAgentToken(node, payload) } 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)) payload.NodeID = node.NodeID - 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 payload.IP == "" { - return nil, errors.New("ip 不能为空") - } - if payload.AgentVersion == "" { - return nil, errors.New("agent_version 不能为空") + payload = normalizeAgentNodePayload(payload) + if err := validateAgentNodePayload(payload); err != nil { + return nil, err } 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 { @@ -237,11 +198,8 @@ func computeNodeStatus(node *model.Node) string { if node == nil { return NodeStatusOffline } - if strings.TrimSpace(node.AgentToken) == "" && strings.TrimSpace(node.DiscoveryToken) != "" { - return NodeStatusPending - } if node.LastSeenAt.IsZero() { - return NodeStatusOffline + return NodeStatusPending } if time.Since(node.LastSeenAt) > common.NodeOfflineThreshold { return NodeStatusOffline diff --git a/atsf_server/service/node.go b/atsf_server/service/node.go index 3433e522..d432602d 100644 --- a/atsf_server/service/node.go +++ b/atsf_server/service/node.go @@ -14,6 +14,10 @@ type NodeInput struct { Name string `json:"name"` } +type NodeBootstrapView struct { + DiscoveryToken string `json:"discovery_token"` +} + type AgentRegistrationResponse struct { NodeID string `json:"node_id"` AgentToken string `json:"agent_token"` @@ -37,7 +41,7 @@ func CreateNode(input NodeInput) (*NodeView, error) { if err != nil { return nil, err } - node.DiscoveryToken, err = newRandomToken() + node.AgentToken, err = newRandomToken() if err != nil { return nil, err } @@ -85,12 +89,61 @@ func AuthenticateAgentToken(token string) (*model.Node, error) { return model.GetNodeByAgentToken(token) } -func AuthenticateDiscoveryToken(token string) (*model.Node, error) { +func ValidateDiscoveryToken(token string) error { token = strings.TrimSpace(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 { @@ -100,6 +153,7 @@ func buildNodeView(node *model.Node) *NodeView { NodeID: node.NodeID, Name: node.Name, IP: node.IP, + AgentToken: node.AgentToken, AgentVersion: node.AgentVersion, NginxVersion: node.NginxVersion, Status: status, @@ -110,12 +164,86 @@ func buildNodeView(node *model.Node) *NodeView { UpdatedAt: node.UpdatedAt, Pending: status == NodeStatusPending, } - if status == NodeStatusPending { - view.DiscoveryToken = node.DiscoveryToken - } 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) { if !preserveName || strings.TrimSpace(node.Name) == "" { if strings.TrimSpace(payload.Name) != "" { diff --git a/atsf_server/web/src/pages/Node/index.js b/atsf_server/web/src/pages/Node/index.js index bdd82584..e8be8b8f 100644 --- a/atsf_server/web/src/pages/Node/index.js +++ b/atsf_server/web/src/pages/Node/index.js @@ -28,6 +28,7 @@ const renderApply = (result) => { const Node = () => { const [nodes, setNodes] = useState([]); + const [bootstrap, setBootstrap] = useState({ discovery_token: '' }); const [loading, setLoading] = useState(false); const [submitting, setSubmitting] = useState(false); const [form, setForm] = useState(initialForm); @@ -45,7 +46,31 @@ const Node = () => { 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(() => { + loadBootstrapToken().then(); loadNodes().then(); }, []); @@ -100,7 +125,20 @@ const Node = () => { return (
节点管理
-

创建节点、查看接入令牌,并跟踪节点在线状态、版本与最近一次应用结果。

+

创建节点后会直接生成节点专属 auth token;批量部署时可复用全局 discovery token 自动注册。

+ +
+ + + + +
@@ -126,7 +164,7 @@ const Node = () => { 节点名 Node ID - Discovery Token + Auth Token IP 状态 Agent / Nginx @@ -143,12 +181,12 @@ const Node = () => { {node.name} {node.node_id} - {node.pending && node.discovery_token ? ( + {node.agent_token ? ( <> - -
{node.discovery_token}
+ {node.pending ? : } +
{node.agent_token}
- ) : '已激活'} + ) : '暂无'}
{node.ip} {renderStatus(node.status)} diff --git a/docs/design.md b/docs/design.md index 4b060bc1..40f8794a 100644 --- a/docs/design.md +++ b/docs/design.md @@ -60,9 +60,10 @@ **2.5.3 Agent 管理与自动发现** * 管理端支持手工创建节点、编辑节点名、删除节点 -* `nodes` 表增加 Agent 鉴权 Token 与自动发现 Token 字段 -* 首次接入不再依赖全局环境变量 Token,而是依赖管理端为节点生成的自动发现 Token -* Agent 首次注册成功后,Server 下发节点专属 Agent Token,Agent 本地完成 Token 置换 +* 管理端手工创建节点时,直接为该节点生成专属 `agent_token` +* 预创建节点时,持有该 `agent_token` 的 Agent 会占据该节点位,并持续以该 Token 完成后续鉴权 +* 系统同时维护一个全局 `discovery_token`,任意新节点可使用该 Token 自动接入 Server +* Agent 使用全局 `discovery_token` 首次注册成功后,Server 会为该节点生成专属 `agent_token`,Agent 本地完成 Token 置换 * Agent 默认自动探测主机名与 IP,也允许通过配置覆盖 **2.5.4 路由增强** @@ -291,13 +292,14 @@ Agent 使用 Go 单体程序: 新增字段建议: -* `agent_token` — 节点专属 Agent Token,用于注册完成后的正式鉴权 -* `discovery_token` — 自动发现 Token,仅用于首次接入 +* `agent_token` — 节点专属 Agent Token,用于节点占位与后续正式鉴权 约束: -* `agent_token` 与 `discovery_token` 都应为随机生成值 -* `discovery_token` 仅用于首次接入,注册成功后应失效或清空 +* 管理端手工创建节点时必须直接生成 `agent_token` +* 一个节点位只对应一个 `agent_token` +* 全局 `discovery_token` 不存放在 `nodes` 表,而由系统配置统一维护 +* 使用全局 `discovery_token` 自动接入的节点,应在注册成功后获得新的专属 `agent_token` * 删除节点后,该节点关联的 Token 必须立即失效 --- diff --git a/docs/development-guidelines.md b/docs/development-guidelines.md index 28270ff1..76cff819 100644 --- a/docs/development-guidelines.md +++ b/docs/development-guidelines.md @@ -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`) -* `nodes.discovery_token` 仅用于首次接入,接入成功后必须失效 +* 管理端手工创建节点时必须直接生成 `nodes.agent_token` +* 全局 `discovery_token` 由系统配置统一保存,不按节点分配 * 删除节点必须立即使该节点凭证失效 如需新增表,必须先证明它服务于当前迭代版本的主链路。 @@ -214,9 +215,10 @@ Agent(第一版): Agent(第二版): -* 首次接入使用 `discovery_token` -* 注册成功后下发节点专属 `agent_token` -* 后续请求改为查 `nodes.agent_token` 验证 +* 手工创建的节点直接使用节点专属 `agent_token` 接入 +* 全局 `discovery_token` 仅用于批量自动发现 +* 使用全局 `discovery_token` 注册成功后,下发节点专属 `agent_token` +* 后续请求统一查 `nodes.agent_token` 验证 * 不再依赖全局环境变量 Agent Token 注意: @@ -303,6 +305,7 @@ Agent 必须满足以下行为: * 启动时先校验本地路由文件 checksum 与控制面激活版本是否一致 * Docker 模式启动时应重建容器,而不是继续复用异常停止的旧容器 * 若本地 `agent_token` 为空且配置了 `discovery_token`,则应自动发起首次注册并完成 Token 置换 +* 若本地已显式配置节点专属 `agent_token`,则无需注册,直接进入心跳与同步流程 ### 7.3 容错规范 diff --git a/docs/development-plan.md b/docs/development-plan.md index 40fa8a4f..a5ac4f50 100644 --- a/docs/development-plan.md +++ b/docs/development-plan.md @@ -123,17 +123,19 @@ MVP 已于第一版完成。当前进入第二版迭代。 * agent-auth 中间件改造(查表验证) * 移除全局 Token 环境变量, 节点不再通过该方式连接server -* 引入自动发现TOKEN, 需要用户手动在节点页创建, 持有该TOKEN的节点会自动连接到SERVER +* 用户手动创建节点时,直接生成节点专属 auth token +* 引入全局自动发现 TOKEN,任意新节点持有同一个 TOKEN 即可自动连接到 SERVER * Node CRUD API * 前端 节点 管理页 完成标准: -* 用户启动server后需要手动在节点页添加节点, 此时会生成随机TOKEN, 持有该TOKEN的节点可以加入server. server可以修改节点的名称 +* 用户启动 server 后手动在节点页添加节点,会直接生成该节点的专属 auth token,持有该 token 的节点可占据该节点位 +* 用户可在管理界面查看全局 discovery token,批量部署的节点可共用该 token 自动注册到 server * 用户可以编辑节点, 包括节点名 * 删除节点后 Agent 请求立即返回 401 * 节点配置文件不再要求填写节点名和IP地址, 节点名默认从主机名获取, IP也是自动获取. 手动指定则为覆盖 -* 节点配置文件agent_token为空, 自动发现TOKEN配置为server生成值后, 会自己向server注册, 注册后会进行TOKEN置换, 更新节点配置文件agent_token +* 节点配置文件填写节点专属 `agent_token` 时,可直接上线;若 `agent_token` 为空且填写全局 discovery token,则会自动注册并完成 token 置换 ### V2 Phase 4: 路由自定义头 @@ -208,8 +210,9 @@ MVP 已于第一版完成。当前进入第二版迭代。 ### V2 Phase 3 检查项 -* 可通过管理界面创建节点并生成 discovery token -* Agent 可使用 discovery token 自动接入并完成 agent token 置换 +* 可通过管理界面创建节点并生成节点专属 auth token +* 可查看全局 discovery token,并允许多个节点共用该 token 自动接入 +* Agent 可使用全局 discovery token 自动接入并完成 agent token 置换 * 用户可编辑节点名并删除节点 * 删除节点后 Agent 请求立即失败 * Agent 在未显式配置节点名/IP 时可自动探测