yao/tai/api/register.go
Max 2dc4307175 feat(tai): enhance node registration and management
- Updated the registration process to utilize NodeID instead of TaiID, allowing for server-generated TaiIDs.
- Implemented additional fields in the registration request, including DisplayName and improved error handling for missing parameters.
- Enhanced the connection logic for registered nodes, ensuring proper client binding and logging for better traceability.
- Introduced new methods for extracting and managing extra claims in OAuth tokens, improving the flexibility of user identification.

Made-with: Cursor
2026-03-10 15:40:41 +08:00

322 lines
9.4 KiB
Go

package api
import (
"fmt"
"log/slog"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/yaoapp/yao/openapi/oauth"
tai "github.com/yaoapp/yao/tai"
"github.com/yaoapp/yao/tai/registry"
"github.com/yaoapp/yao/tai/taiid"
)
// authenticateBearer validates a Bearer token and returns the caller's identity.
// Package-level var so tests can inject a mock without an OAuth service.
var authenticateBearer = authenticateBearerDefault
func authenticateBearerDefault(token string) (registry.AuthInfo, error) {
svc := oauth.OAuth
if svc == nil {
return registry.AuthInfo{}, fmt.Errorf("oauth service not initialized")
}
result, err := svc.AuthenticateToken(oauth.AuthInput{AccessToken: token})
if err != nil {
return registry.AuthInfo{}, err
}
info := registry.AuthInfo{}
if result.Info != nil {
info.Subject = result.Info.Subject
info.UserID = result.Info.UserID
info.ClientID = result.Info.ClientID
info.Scope = result.Info.Scope
info.TeamID = result.Info.TeamID
info.TenantID = result.Info.TenantID
}
slog.Info("[auth] buildAuthInfo result",
"subject", info.Subject, "user_id", info.UserID,
"client_id", info.ClientID, "team_id", info.TeamID,
"scope", info.Scope)
if result.Claims != nil {
slog.Info("[auth] claims",
"claims.TeamID", result.Claims.TeamID,
"claims.TenantID", result.Claims.TenantID,
"claims.ClientID", result.Claims.ClientID,
"claims.Subject", result.Claims.Subject)
if result.Claims.Extra != nil {
slog.Info("[auth] claims.Extra", "extra", fmt.Sprintf("%+v", result.Claims.Extra))
} else {
slog.Info("[auth] claims.Extra is nil")
}
if info.TeamID == "" {
switch v := result.Claims.Extra["team_id"].(type) {
case string:
info.TeamID = v
slog.Info("[auth] team_id from Extra (string)", "team_id", v)
case float64:
info.TeamID = fmt.Sprintf("%.0f", v)
slog.Info("[auth] team_id from Extra (float64)", "team_id", info.TeamID)
default:
slog.Info("[auth] team_id not found in Extra or unknown type",
"type", fmt.Sprintf("%T", result.Claims.Extra["team_id"]),
"value", fmt.Sprintf("%v", result.Claims.Extra["team_id"]))
}
}
if info.TenantID == "" {
if v, ok := result.Claims.Extra["tenant_id"].(string); ok {
info.TenantID = v
}
}
}
return info, nil
}
func extractBearer(r *http.Request) string {
auth := r.Header.Get("Authorization")
if len(auth) > 7 && strings.EqualFold(auth[:7], "bearer ") {
return auth[7:]
}
return ""
}
// registerRequest is the JSON body for POST /tai-nodes/register.
type registerRequest struct {
NodeID string `json:"node_id,omitempty"`
ClientID string `json:"client_id,omitempty"`
MachineID string `json:"machine_id"`
DisplayName string `json:"display_name,omitempty"`
Version string `json:"version"`
Addr string `json:"addr"`
Ports map[string]int `json:"ports"`
Capabilities map[string]bool `json:"capabilities"`
System registry.SystemInfo `json:"system"`
}
// heartbeatRequest is the JSON body for POST /tai-nodes/heartbeat.
type heartbeatRequest struct {
TaiID string `json:"tai_id"`
}
// HandleRegister handles POST /tai-nodes/register.
// Validates Bearer token, extracts AuthInfo, and writes the node to the Registry.
func HandleRegister(c *gin.Context) {
reg := registry.Global()
if reg == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "registry not initialized"})
return
}
bearer := extractBearer(c.Request)
if bearer == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing authorization"})
return
}
authInfo, err := authenticateBearer(bearer)
if err != nil {
slog.Warn("tai register auth failed", "err", err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication failed"})
return
}
var req registerRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
if req.NodeID == "" || req.MachineID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "node_id and machine_id are required"})
return
}
resolvedTaiID, err := taiid.Generate(req.MachineID, req.NodeID)
if err != nil {
slog.Warn("taiid generation failed", "err", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to generate tai_id"})
return
}
remoteIP := c.ClientIP()
addr := req.Addr
if addr == "" && remoteIP != "" {
grpcPort := req.Ports["grpc"]
if grpcPort > 0 {
addr = fmt.Sprintf("tai://%s:%d", remoteIP, grpcPort)
} else {
addr = remoteIP
}
}
node := &registry.TaiNode{
TaiID: resolvedTaiID,
MachineID: req.MachineID,
Version: req.Version,
DisplayName: req.DisplayName,
Auth: authInfo,
System: req.System,
Mode: "direct",
Addr: addr,
Ports: req.Ports,
Capabilities: req.Capabilities,
}
reg.Register(node)
slog.Info("[register] node registered via API",
"tai_id", resolvedTaiID, "addr", addr, "remote_ip", remoteIP,
"user_id", authInfo.UserID, "team_id", authInfo.TeamID)
allBefore := reg.List()
slog.Info("[register] registry snapshot after Register",
"total", len(allBefore))
for _, s := range allBefore {
slog.Info("[register] node", "tai_id", s.TaiID, "mode", s.Mode, "addr", s.Addr)
}
if strings.HasPrefix(addr, "tai://") {
slog.Info("[register] launching connectRegisteredNode goroutine",
"tai_id", resolvedTaiID, "addr", addr)
go connectRegisteredNode(resolvedTaiID, addr, reg)
}
c.JSON(http.StatusOK, gin.H{
"status": "registered",
"tai_id": resolvedTaiID,
"remote_ip": remoteIP,
})
}
// HandleHeartbeat handles POST /tai-nodes/heartbeat.
// Validates Bearer token and updates the node's last ping timestamp.
func HandleHeartbeat(c *gin.Context) {
reg := registry.Global()
if reg == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "registry not initialized"})
return
}
bearer := extractBearer(c.Request)
if bearer == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing authorization"})
return
}
authInfo, err := authenticateBearer(bearer)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication failed"})
return
}
var req heartbeatRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
if req.TaiID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "tai_id is required"})
return
}
snap, ok := reg.Get(req.TaiID)
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "tai node not found"})
return
}
if snap.Auth.ClientID != authInfo.ClientID {
c.JSON(http.StatusForbidden, gin.H{"error": "tai_id does not belong to this client"})
return
}
reg.UpdatePing(req.TaiID)
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
// HandleUnregister handles DELETE /tai-nodes/register/:tai_id.
// Validates Bearer token, checks ownership, and removes the node.
func HandleUnregister(c *gin.Context) {
reg := registry.Global()
if reg == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "registry not initialized"})
return
}
bearer := extractBearer(c.Request)
if bearer == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing authorization"})
return
}
authInfo, err := authenticateBearer(bearer)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication failed"})
return
}
taiID := c.Param("tai_id")
if taiID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "tai_id is required"})
return
}
snap, ok := reg.Get(taiID)
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "tai node not found"})
return
}
if snap.Auth.ClientID != authInfo.ClientID {
c.JSON(http.StatusForbidden, gin.H{"error": "tai_id does not belong to this client"})
return
}
reg.Unregister(taiID)
slog.Info("tai node unregistered via API", "tai_id", taiID, "user_id", authInfo.UserID)
c.JSON(http.StatusOK, gin.H{"status": "unregistered"})
}
// connectRegisteredNode dials the self-registered Tai node via gRPC,
// creates a tai.Client, and binds it to the node's TaiID in the registry.
// initRemote internally registers a redundant "host-port" entry; we remove
// it so that the registry contains only the canonical taiID.
func connectRegisteredNode(taiID, addr string, reg *registry.Registry) {
slog.Info("[connect] start", "tai_id", taiID, "addr", addr)
client, err := tai.New(addr)
if err != nil {
slog.Warn("[connect] tai.New FAILED",
"tai_id", taiID, "addr", addr, "err", err)
allAfterFail := reg.List()
slog.Info("[connect] registry after tai.New failure", "total", len(allAfterFail))
for _, s := range allAfterFail {
slog.Info("[connect] node", "tai_id", s.TaiID, "mode", s.Mode, "addr", s.Addr)
}
return
}
autoID := client.TaiID()
slog.Info("[connect] tai.New OK", "tai_id", taiID, "autoID", autoID)
allAfterNew := reg.List()
slog.Info("[connect] registry after tai.New", "total", len(allAfterNew))
for _, s := range allAfterNew {
slog.Info("[connect] node", "tai_id", s.TaiID, "mode", s.Mode, "addr", s.Addr)
}
if autoID != "" && autoID != taiID {
slog.Info("[connect] removing redundant autoID", "autoID", autoID)
reg.Unregister(autoID)
}
reg.SetClient(taiID, client)
allFinal := reg.List()
slog.Info("[connect] registry FINAL", "total", len(allFinal))
for _, s := range allFinal {
slog.Info("[connect] node", "tai_id", s.TaiID, "mode", s.Mode, "addr", s.Addr)
}
slog.Info("[connect] done", "tai_id", taiID)
}