yao/tai/api/register.go
Max 83ebe49036 refactor: unify server lifecycle, migrate gRPC client, and clean up sandbox v2
Server lifecycle:
- Introduce service.Service to manage HTTP + gRPC startup/shutdown
- Fix gRPC mutex deadlock in StartServer when port is occupied
- Add GracefulStop with 5s timeout before forced Stop in grpc.go
- Pre-check HTTP and gRPC port availability in cmd/start.go
- Print gRPC server address in startup access-points block

gRPC client refactor:
- Move token manager and client from tai/grpc/ to grpc/client/
- Add backward-compatible aliases in tai/yao.go and tai/token.go
- Update cmd/run.go to import grpc/client directly (no tai dependency)

Sandbox v2 docker migration:
- Delete sandbox/v2/docker/ (moved to tai repo)
- Update sandbox/docker/build.sh hint to point to tai repo
- Clean up .gitignore entries for removed docker directory
- Temporarily disable SandboxV2Test and BenchmarkSandboxV2 in CI
  (docker images need rebuild after tai repo migration)

Tai integration:
- Add direct-mode registration API handlers in tai/api/
- Add heartbeat handler and token management wrappers
- Update tai/registry and tai/tunnel for latest protocol
- Replace yao-grpc references with tai call in docs

Made-with: Cursor
2026-03-07 17:19:19 +08:00

205 lines
5.5 KiB
Go

package api
import (
"fmt"
"log/slog"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/yaoapp/yao/openapi/oauth"
"github.com/yaoapp/yao/tai/registry"
)
// 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
}
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 {
TaiID string `json:"tai_id"`
MachineID string `json:"machine_id"`
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.TaiID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "tai_id is required"})
return
}
node := &registry.TaiNode{
TaiID: req.TaiID,
MachineID: req.MachineID,
Version: req.Version,
Auth: authInfo,
System: req.System,
Mode: "direct",
Addr: req.Addr,
Ports: req.Ports,
Capabilities: req.Capabilities,
}
reg.Register(node)
remoteIP := c.ClientIP()
slog.Info("tai node registered via API",
"tai_id", req.TaiID, "remote_ip", remoteIP, "user_id", authInfo.UserID)
c.JSON(http.StatusOK, gin.H{
"status": "registered",
"tai_id": req.TaiID,
"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"})
}