yao/tai/registry/registry.go
Max b667ec310b feat(registry, grpc): introduce channel ID constants and logging enhancements
- Added constants for channel ID byte size and short length to improve clarity and maintainability.
- Implemented ShortChannelID function to truncate channel IDs for log messages, enhancing readability.
- Updated gRPC tunnel logging to utilize the new ShortChannelID function, ensuring consistent and concise channel ID representation in logs.

Made-with: Cursor
2026-03-13 18:41:15 +08:00

438 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package registry
import (
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"log/slog"
"net"
"os"
"strings"
"sync"
"time"
"github.com/yaoapp/yao/tai/types"
)
// TaiNode represents a registered Tai instance (direct or tunnel).
// Internal use only; external callers receive types.NodeMeta via Get()/List().
type TaiNode struct {
TaiID string
MachineID string
Version string
Auth types.AuthInfo
System types.SystemInfo
Mode string // "direct" | "tunnel"
Addr string // direct mode: "tai-host"; tunnel mode: empty
YaoBase string // Yao server base URL reported by Tai (tunnel mode)
Ports types.Ports
Capabilities types.Capabilities
registerStream any // taipb.TaiTunnel_RegisterServer (stored as any to avoid import cycle)
Status string // "online" | "offline" | "connecting"
ConnectedAt time.Time
LastPing time.Time
DisplayName string // optional human-readable name for UI
resources any // *tai.ConnResources; stored as any to avoid import cycle
localListeners map[int]*tunnelListener
}
func (n *TaiNode) meta() types.NodeMeta {
return types.NodeMeta{
TaiID: n.TaiID, MachineID: n.MachineID, Version: n.Version,
Auth: n.Auth, System: n.System,
Mode: n.Mode, Addr: n.Addr, YaoBase: n.YaoBase,
Ports: n.Ports, Capabilities: n.Capabilities,
Status: n.Status, ConnectedAt: n.ConnectedAt, LastPing: n.LastPing,
DisplayName: n.DisplayName,
}
}
// tunnelListener wraps a TCP listener that bridges each accepted connection
// through the tunnel to a specific Tai port.
type tunnelListener struct {
listener net.Listener
taiID string
port int
cancel func()
}
var (
global *Registry
once sync.Once
)
// BridgeFunc bridges a local TCP connection to a target port on a tunnel node.
// Set via SetBridgeFunc once the gRPC tunnel handler is ready.
type BridgeFunc func(taiID string, targetPort int, localConn net.Conn)
// Registry manages all Tai nodes (direct and tunnel).
type Registry struct {
mu sync.RWMutex
nodes map[string]*TaiNode
logger *slog.Logger
bridgeFn BridgeFunc
bridgeMu sync.RWMutex
}
// Init initializes the global registry singleton.
func Init(logger *slog.Logger) {
once.Do(func() {
if logger == nil {
logger = slog.Default()
}
global = &Registry{
nodes: make(map[string]*TaiNode),
logger: logger,
}
})
}
// InitWithWriter initializes the global registry using the given io.Writer
// and log format ("JSON" or "TEXT"). If w is nil it falls back to stderr.
// This is the preferred way to integrate with the application log system.
func InitWithWriter(w io.Writer, logMode string) {
if w == nil {
w = os.Stderr
}
opts := &slog.HandlerOptions{Level: slog.LevelInfo}
var handler slog.Handler
if strings.EqualFold(logMode, "JSON") {
handler = slog.NewJSONHandler(w, opts)
} else {
handler = slog.NewTextHandler(w, opts)
}
Init(slog.New(handler))
}
// Global returns the global registry instance.
func Global() *Registry {
return global
}
// Register adds or updates a Tai node in the registry.
func (r *Registry) Register(node *TaiNode) {
r.mu.Lock()
defer r.mu.Unlock()
node.Status = "online"
node.ConnectedAt = time.Now()
node.LastPing = time.Now()
if node.localListeners == nil {
node.localListeners = make(map[int]*tunnelListener)
}
r.nodes[node.TaiID] = node
r.logger.Info("tai node registered",
"tai_id", node.TaiID, "mode", node.Mode, "version", node.Version)
}
// Unregister removes a Tai node, closes its local listeners,
// and any held ConnResources.
func (r *Registry) Unregister(taiID string) {
r.mu.Lock()
node, ok := r.nodes[taiID]
if ok {
for _, tl := range node.localListeners {
tl.cancel()
tl.listener.Close()
}
delete(r.nodes, taiID)
}
r.mu.Unlock()
if ok {
if node.resources != nil {
if closer, ok := node.resources.(ResourceCloser); ok {
closer.Close()
}
}
r.logger.Info("tai node unregistered", "tai_id", taiID)
}
}
// Get returns the metadata of a Tai node by ID. Returns nil, false if not found.
func (r *Registry) Get(taiID string) (*types.NodeMeta, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
n, ok := r.nodes[taiID]
if !ok {
return nil, false
}
m := n.meta()
return &m, true
}
// List returns metadata of all registered Tai nodes.
func (r *Registry) List() []types.NodeMeta {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]types.NodeMeta, 0, len(r.nodes))
for _, n := range r.nodes {
result = append(result, n.meta())
}
return result
}
// UpdatePing records a heartbeat timestamp.
func (r *Registry) UpdatePing(taiID string) {
r.mu.Lock()
defer r.mu.Unlock()
if n, ok := r.nodes[taiID]; ok {
n.LastPing = time.Now()
}
}
// ResourceCloser is implemented by *tai.ConnResources to allow the registry
// to close resources without importing the tai package (avoids import cycle).
type ResourceCloser interface {
Close() error
}
// SetResources binds connection resources to a registered node.
// If the node already has resources, the old ones are closed asynchronously.
// The node status is set to "online".
func (r *Registry) SetResources(taiID string, res any) {
r.mu.Lock()
defer r.mu.Unlock()
n, ok := r.nodes[taiID]
if !ok {
return
}
if n.resources != nil {
if closer, ok := n.resources.(ResourceCloser); ok {
go closer.Close()
}
}
n.resources = res
n.Status = "online"
}
// GetResources returns the *tai.ConnResources for a node (as any).
// Callers should type-assert to *tai.ConnResources.
func (r *Registry) GetResources(taiID string) (any, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
n, ok := r.nodes[taiID]
if !ok || n.resources == nil {
return nil, false
}
return n.resources, true
}
// SetBridgeFunc sets the function used by OpenLocalListener to bridge
// TCP connections through the gRPC tunnel (Forward stream).
func (r *Registry) SetBridgeFunc(fn BridgeFunc) {
r.bridgeMu.Lock()
defer r.bridgeMu.Unlock()
r.bridgeFn = fn
}
// SetRegisterStream stores the gRPC Register stream for a tunnel node.
func (r *Registry) SetRegisterStream(taiID string, stream any) {
r.mu.Lock()
defer r.mu.Unlock()
if n, ok := r.nodes[taiID]; ok {
n.registerStream = stream
}
}
// GetRegisterStream returns the gRPC Register stream for a tunnel node.
func (r *Registry) GetRegisterStream(taiID string) any {
r.mu.RLock()
defer r.mu.RUnlock()
if n, ok := r.nodes[taiID]; ok {
return n.registerStream
}
return nil
}
// GenerateChannelID creates a random channel ID for Forward stream matching.
func GenerateChannelID() (string, error) {
return generateChannelID()
}
// FindTaiIDByAuthClient returns the TaiID of the first node whose
// Auth.ClientID matches the given OAuth client ID. Returns "" if not found.
func (r *Registry) FindTaiIDByAuthClient(clientID string) string {
r.mu.RLock()
defer r.mu.RUnlock()
for _, n := range r.nodes {
if n.Auth.ClientID == clientID {
return n.TaiID
}
}
return ""
}
// ListByTeam returns metadata of all nodes belonging to the given team.
func (r *Registry) ListByTeam(teamID string) []types.NodeMeta {
r.mu.RLock()
defer r.mu.RUnlock()
var result []types.NodeMeta
for _, n := range r.nodes {
if n.Auth.TeamID == teamID {
result = append(result, n.meta())
}
}
return result
}
// ListByUser returns metadata of all nodes registered by the given user
// that are NOT associated with any team.
func (r *Registry) ListByUser(userID string) []types.NodeMeta {
r.mu.RLock()
defer r.mu.RUnlock()
var result []types.NodeMeta
for _, n := range r.nodes {
if n.Auth.TeamID == "" && n.Auth.UserID == userID {
result = append(result, n.meta())
}
}
return result
}
// StartHealthCheck runs a background goroutine that periodically checks
// direct-mode nodes for heartbeat timeout. Nodes whose LastPing exceeds
// timeout are marked offline. Nodes that remain offline longer than
// cleanupAfter are automatically unregistered.
// The goroutine stops when ctx.Done() is closed.
func (r *Registry) StartHealthCheck(done <-chan struct{}, interval, timeout, cleanupAfter time.Duration) {
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-done:
return
case <-ticker.C:
r.checkHealth(timeout, cleanupAfter)
}
}
}()
}
func (r *Registry) checkHealth(timeout, cleanupAfter time.Duration) {
now := time.Now()
var toRemove []string
r.mu.Lock()
for id, n := range r.nodes {
if n.Mode != "direct" {
continue
}
elapsed := now.Sub(n.LastPing)
if n.Status == "online" && elapsed > timeout {
n.Status = "offline"
r.logger.Warn("tai node offline (heartbeat timeout)",
"tai_id", id, "last_ping", n.LastPing)
}
if n.Status == "offline" && elapsed > timeout+cleanupAfter {
toRemove = append(toRemove, id)
}
}
r.mu.Unlock()
for _, id := range toRemove {
r.logger.Info("tai node auto-unregistered (offline too long)", "tai_id", id)
r.Unregister(id)
}
}
// OpenLocalListener creates a localhost TCP listener that tunnels every
// accepted connection to the specified port on the given Tai node.
// Returns the listener address (e.g. "127.0.0.1:54321").
func (r *Registry) OpenLocalListener(taiID string, targetPort int) (net.Listener, error) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return nil, fmt.Errorf("listen: %w", err)
}
ctx, cancel := newContext()
tl := &tunnelListener{listener: ln, taiID: taiID, port: targetPort, cancel: cancel}
r.mu.Lock()
node := r.nodes[taiID]
if node == nil {
r.mu.Unlock()
cancel()
ln.Close()
return nil, fmt.Errorf("tai node %s not found", taiID)
}
node.localListeners[targetPort] = tl
r.mu.Unlock()
go func() {
for {
conn, err := ln.Accept()
if err != nil {
select {
case <-ctx.Done():
return
default:
r.logger.Debug("tunnel listener accept error", "err", err)
return
}
}
go r.bridgeTunnelConn(taiID, targetPort, conn)
}
}()
r.logger.Info("tunnel local listener started",
"tai_id", taiID, "target_port", targetPort, "local_addr", ln.Addr().String())
return ln, nil
}
func (r *Registry) bridgeTunnelConn(taiID string, targetPort int, localConn net.Conn) {
r.bridgeMu.RLock()
fn := r.bridgeFn
r.bridgeMu.RUnlock()
if fn != nil {
fn(taiID, targetPort, localConn)
return
}
localConn.Close()
r.logger.Error("no bridge function configured", "tai_id", taiID, "port", targetPort)
}
// ChannelIDBytes is the number of random bytes used to generate a channel ID.
// The resulting hex string is 2× this value (64 characters).
const ChannelIDBytes = 32
// ChannelIDShortLen is the max characters shown in log messages.
const ChannelIDShortLen = 16
func generateChannelID() (string, error) {
b := make([]byte, ChannelIDBytes)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// ShortChannelID truncates a channel ID for log display.
func ShortChannelID(id string) string {
if len(id) <= ChannelIDShortLen {
return id
}
return id[:ChannelIDShortLen]
}
type contextCancel struct {
done chan struct{}
}
func newContext() (*contextCancel, func()) {
cc := &contextCancel{done: make(chan struct{})}
return cc, func() { close(cc.done) }
}
func (c *contextCancel) Done() <-chan struct{} {
return c.done
}