사용자별 credential 저장, lease, projection, runtime 전달과 OpenAI-compatible 계약 및 검증 근거를 함께 반영한다.
516 lines
17 KiB
Go
516 lines
17 KiB
Go
package wire
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"sort"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
proto_socket "git.toki-labs.com/toki/proto-socket/go"
|
|
"iop/packages/go/auth"
|
|
iop "iop/proto/gen/iop"
|
|
)
|
|
|
|
// EdgeServer hosts the Control Plane-Edge native TCP wire endpoint. It accepts
|
|
// Edge connections over proto-socket, handles the hello handshake, and keeps an
|
|
// internal connection registry. It does not connect to or schedule Node
|
|
// directly; the Control Plane observes and controls the system through Edge.
|
|
type EdgeServer struct {
|
|
server *proto_socket.TcpServer
|
|
registry *EdgeRegistry
|
|
logger *zap.Logger
|
|
host string
|
|
port int
|
|
|
|
// activeMu guards activeClients, the live proto-socket client per edge_id
|
|
// the Control Plane uses to send typed requests (e.g. status) to a connected
|
|
// Edge. token mirrors the registry connection token so a stale connection's
|
|
// late disconnect does not evict a newer reconnection's client.
|
|
activeMu sync.Mutex
|
|
activeClients map[string]activeEdgeClient
|
|
projection func(context.Context) (*iop.PrincipalProjection, error)
|
|
leaseIssuer func(context.Context, *iop.AcquireLeaseRequest) (*iop.AcquireLeaseResponse, error)
|
|
requirePeerID bool
|
|
|
|
refreshMu sync.Mutex
|
|
refreshInterval time.Duration
|
|
refreshCancel context.CancelFunc
|
|
refreshDone chan struct{}
|
|
broadcastGate chan struct{}
|
|
}
|
|
|
|
type activeEdgeClient struct {
|
|
client *proto_socket.TcpClient
|
|
token uint64
|
|
}
|
|
|
|
// projectionClient is a snapshotted broadcast target: the Edge id and the live
|
|
// proto-socket client captured under activeMu so a fan-out push never touches
|
|
// the shared registry while it runs.
|
|
type projectionClient struct {
|
|
edgeID string
|
|
client *proto_socket.TcpClient
|
|
}
|
|
|
|
// projectionPushTimeout bounds a single per-Edge projection push. proto-socket's
|
|
// SendRequest starts its own request timer only after QueuePacket returns, and a
|
|
// TCP write has no deadline, so a non-reading Edge can stall the write forever.
|
|
// This deadline is enforced by BroadcastProjection itself, which closes the
|
|
// stalled client to unblock the write when it expires.
|
|
const projectionPushTimeout = 10 * time.Second
|
|
|
|
// NewEdgeServer creates an EdgeServer bound to the given TCP listen address.
|
|
func NewEdgeServer(listenAddr string, logger *zap.Logger) (*EdgeServer, error) {
|
|
return NewEdgeServerTLS(listenAddr, nil, logger)
|
|
}
|
|
|
|
// NewEdgeServerTLS creates an Edge wire server. A non-nil TLS configuration
|
|
// is retained by the proto-socket server for every accepted connection and
|
|
// reconnect; there is no plaintext retry path.
|
|
func NewEdgeServerTLS(listenAddr string, tlsConfig *tls.Config, logger *zap.Logger) (*EdgeServer, error) {
|
|
host, portStr, err := net.SplitHostPort(listenAddr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid edge listen address %q: %w", listenAddr, err)
|
|
}
|
|
port, err := strconv.Atoi(portStr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid port in edge listen address %q: %w", listenAddr, err)
|
|
}
|
|
|
|
registry := NewEdgeRegistry()
|
|
es := &EdgeServer{
|
|
registry: registry,
|
|
logger: logger,
|
|
host: host,
|
|
port: port,
|
|
activeClients: make(map[string]activeEdgeClient),
|
|
requirePeerID: tlsConfig != nil,
|
|
broadcastGate: make(chan struct{}, 1),
|
|
}
|
|
|
|
newClient := func(conn net.Conn) *proto_socket.TcpClient {
|
|
client := proto_socket.NewTcpClient(conn, EdgeHeartbeatIntervalSec, EdgeHeartbeatWaitSec, EdgeParserMap())
|
|
|
|
// edgeID and token are set once the hello handshake identifies the
|
|
// connection so the disconnect listener can flip the right registry entry.
|
|
// The token lets a stale connection's late disconnect be ignored after the
|
|
// same edge_id reconnected. The hello and disconnect callbacks may run on
|
|
// different goroutines, so guard them.
|
|
var (
|
|
idMu sync.Mutex
|
|
edgeID string
|
|
token uint64
|
|
)
|
|
|
|
proto_socket.AddListenerTyped[*iop.EdgeNodeEvent](&client.Communicator, func(event *iop.EdgeNodeEvent) {
|
|
idMu.Lock()
|
|
id := edgeID
|
|
tok := token
|
|
idMu.Unlock()
|
|
if id == "" {
|
|
return
|
|
}
|
|
registry.RecordNodeEventFromConnection(id, tok, event, time.Now())
|
|
})
|
|
|
|
proto_socket.AddListenerTyped[*iop.EdgeCommandEvent](&client.Communicator, func(event *iop.EdgeCommandEvent) {
|
|
idMu.Lock()
|
|
id := edgeID
|
|
tok := token
|
|
idMu.Unlock()
|
|
if id == "" {
|
|
return
|
|
}
|
|
registry.RecordCommandEventFromConnection(id, tok, event, time.Now())
|
|
})
|
|
|
|
proto_socket.AddRequestListenerTyped(&client.Communicator, func(req *iop.EdgeHelloRequest) (*iop.EdgeHelloResponse, error) {
|
|
now := time.Now()
|
|
if req.GetEdgeId() == "" {
|
|
logger.Warn("edge hello rejected: missing edge_id")
|
|
return &iop.EdgeHelloResponse{
|
|
Accepted: false,
|
|
Protocol: Protocol,
|
|
ServerTimeUnixNano: now.UnixNano(),
|
|
Reason: "edge_id is required",
|
|
}, nil
|
|
}
|
|
if es.requirePeerID {
|
|
identity, identityErr := auth.PeerWorkloadIdentity(conn)
|
|
if identityErr != nil || identity.Role != "edge" || identity.Name != req.GetEdgeId() {
|
|
logger.Warn("edge hello rejected: authenticated identity mismatch",
|
|
zap.String("edge_id", req.GetEdgeId()),
|
|
)
|
|
return &iop.EdgeHelloResponse{
|
|
Accepted: false,
|
|
Protocol: Protocol,
|
|
ServerTimeUnixNano: now.UnixNano(),
|
|
Reason: "authenticated edge identity mismatch",
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
var projection *iop.PrincipalProjection
|
|
if es.projection != nil {
|
|
var projectionErr error
|
|
projection, projectionErr = es.projection(context.Background())
|
|
if projectionErr != nil {
|
|
return &iop.EdgeHelloResponse{Accepted: false, Protocol: Protocol, ServerTimeUnixNano: now.UnixNano(), Reason: "credential projection unavailable"}, nil
|
|
}
|
|
}
|
|
|
|
connToken := registry.MarkConnected(req, now)
|
|
idMu.Lock()
|
|
edgeID = req.GetEdgeId()
|
|
token = connToken
|
|
idMu.Unlock()
|
|
es.setActiveClient(req.GetEdgeId(), connToken, client)
|
|
|
|
logger.Info("edge hello accepted",
|
|
zap.String("edge_id", req.GetEdgeId()),
|
|
zap.String("edge_name", req.GetEdgeName()),
|
|
zap.String("version", req.GetVersion()),
|
|
)
|
|
return &iop.EdgeHelloResponse{
|
|
Accepted: true,
|
|
Protocol: Protocol,
|
|
ServerTimeUnixNano: now.UnixNano(),
|
|
Message: "Edge enrolled on IOP Control Plane",
|
|
PrincipalProjection: projection,
|
|
}, nil
|
|
})
|
|
|
|
proto_socket.AddRequestListenerTyped(&client.Communicator, func(req *iop.AcquireLeaseRequest) (*iop.AcquireLeaseResponse, error) {
|
|
idMu.Lock()
|
|
id := edgeID
|
|
idMu.Unlock()
|
|
if es.leaseIssuer == nil || id == "" || req.GetEdgeId() != id {
|
|
return &iop.AcquireLeaseResponse{Error: "credential lease binding rejected"}, nil
|
|
}
|
|
return es.leaseIssuer(context.Background(), req)
|
|
})
|
|
|
|
client.AddDisconnectListener(func(c *proto_socket.TcpClient) {
|
|
idMu.Lock()
|
|
id := edgeID
|
|
tok := token
|
|
idMu.Unlock()
|
|
if id == "" {
|
|
return
|
|
}
|
|
info := c.DisconnectInfo()
|
|
if registry.MarkDisconnected(id, tok, time.Now(), info) {
|
|
es.clearActiveClient(id, tok)
|
|
}
|
|
logger.Info("edge disconnected",
|
|
zap.String("edge_id", id),
|
|
zap.String("reason", info.Reason),
|
|
)
|
|
})
|
|
|
|
return client
|
|
}
|
|
if tlsConfig != nil {
|
|
es.server = proto_socket.NewTcpServerTLS(host, port, tlsConfig, newClient)
|
|
} else {
|
|
es.server = proto_socket.NewTcpServer(host, port, newClient)
|
|
}
|
|
|
|
return es, nil
|
|
}
|
|
|
|
// Start begins accepting Edge connections on the configured TCP endpoint.
|
|
func (s *EdgeServer) Start(ctx context.Context) error {
|
|
s.logger.Info("starting edge wire TCP server",
|
|
zap.String("host", s.host),
|
|
zap.Int("port", s.port),
|
|
zap.String("transport", EdgeTransport),
|
|
)
|
|
if err := s.server.Start(ctx); err != nil {
|
|
return err
|
|
}
|
|
s.startProjectionRefresh(ctx)
|
|
return nil
|
|
}
|
|
|
|
// Stop stops the Edge wire TCP server and closes active connections.
|
|
func (s *EdgeServer) Stop() error {
|
|
s.logger.Info("stopping edge wire TCP server")
|
|
s.stopProjectionRefresh()
|
|
return s.server.Stop()
|
|
}
|
|
|
|
// Registry exposes the internal Edge connection registry.
|
|
func (s *EdgeServer) Registry() *EdgeRegistry { return s.registry }
|
|
|
|
// SetCredentialPlane installs the secret-free projection source and lease
|
|
// issuer before Start. Both callbacks are reached only from authenticated Edge
|
|
// connections when the server itself is configured with mTLS.
|
|
func (s *EdgeServer) SetCredentialPlane(projection func(context.Context) (*iop.PrincipalProjection, error), issuer func(context.Context, *iop.AcquireLeaseRequest) (*iop.AcquireLeaseResponse, error), refreshInterval time.Duration) {
|
|
s.projection = projection
|
|
s.leaseIssuer = issuer
|
|
s.refreshInterval = refreshInterval
|
|
}
|
|
|
|
func (s *EdgeServer) startProjectionRefresh(parent context.Context) {
|
|
if s == nil || s.projection == nil || s.refreshInterval <= 0 {
|
|
return
|
|
}
|
|
s.refreshMu.Lock()
|
|
defer s.refreshMu.Unlock()
|
|
if s.refreshCancel != nil {
|
|
return
|
|
}
|
|
ctx, cancel := context.WithCancel(parent)
|
|
done := make(chan struct{})
|
|
s.refreshCancel = cancel
|
|
s.refreshDone = done
|
|
interval := s.refreshInterval
|
|
go func() {
|
|
defer close(done)
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
if err := s.BroadcastProjection(ctx); err != nil && ctx.Err() == nil {
|
|
s.logger.Warn("periodic credential projection refresh failed", zap.Error(err))
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
func (s *EdgeServer) stopProjectionRefresh() {
|
|
if s == nil {
|
|
return
|
|
}
|
|
s.refreshMu.Lock()
|
|
cancel := s.refreshCancel
|
|
done := s.refreshDone
|
|
s.refreshMu.Unlock()
|
|
if cancel != nil {
|
|
cancel()
|
|
}
|
|
if done != nil {
|
|
<-done
|
|
}
|
|
s.refreshMu.Lock()
|
|
if s.refreshDone == done {
|
|
s.refreshCancel = nil
|
|
s.refreshDone = nil
|
|
}
|
|
s.refreshMu.Unlock()
|
|
}
|
|
|
|
// BroadcastProjection pushes a freshly committed snapshot to every currently
|
|
// authenticated Edge. A failed Edge remains on its old generation, where both
|
|
// its local fence and the issuer's durable generation check fail closed.
|
|
func (s *EdgeServer) BroadcastProjection(ctx context.Context) error {
|
|
return s.broadcastProjection(ctx, projectionPushTimeout)
|
|
}
|
|
|
|
func (s *EdgeServer) acquireProjectionBatch(ctx context.Context) error {
|
|
select {
|
|
case s.broadcastGate <- struct{}{}:
|
|
return nil
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
|
|
func (s *EdgeServer) releaseProjectionBatch() {
|
|
<-s.broadcastGate
|
|
}
|
|
|
|
// broadcastProjection snapshots the active clients under activeMu, then pushes
|
|
// the projection to every target concurrently so no Edge waits behind another.
|
|
// A private context-aware batch gate still serializes whole batches. Each push
|
|
// is bounded by pushTimeout and by ctx: a stalled or cancelled push closes its
|
|
// client to unblock the socket write, waits for the request goroutine, and
|
|
// reports an Edge-qualified error, so neither the batch nor EdgeServer.Stop can
|
|
// be held open by a non-reading Edge.
|
|
func (s *EdgeServer) broadcastProjection(ctx context.Context, pushTimeout time.Duration) error {
|
|
if s == nil || s.projection == nil {
|
|
return fmt.Errorf("credential projection unavailable")
|
|
}
|
|
if err := s.acquireProjectionBatch(ctx); err != nil {
|
|
return err
|
|
}
|
|
defer s.releaseProjectionBatch()
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
projection, err := s.projection(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.activeMu.Lock()
|
|
clients := make([]projectionClient, 0, len(s.activeClients))
|
|
for edgeID, entry := range s.activeClients {
|
|
clients = append(clients, projectionClient{edgeID: edgeID, client: entry.client})
|
|
}
|
|
s.activeMu.Unlock()
|
|
sort.Slice(clients, func(i, j int) bool {
|
|
return clients[i].edgeID < clients[j].edgeID
|
|
})
|
|
|
|
failures := make([]error, len(clients))
|
|
var sends sync.WaitGroup
|
|
for index, target := range clients {
|
|
sends.Add(1)
|
|
go func() {
|
|
defer sends.Done()
|
|
failures[index] = s.pushProjection(ctx, target, projection, pushTimeout)
|
|
}()
|
|
}
|
|
sends.Wait()
|
|
return errors.Join(failures...)
|
|
}
|
|
|
|
// pushProjection sends the projection to one Edge and bounds it. The typed
|
|
// request runs on its own goroutine; the caller selects on that result, ctx, and
|
|
// a per-client deadline. On cancellation or deadline it closes the target client
|
|
// so a blocked queue/write returns, then waits for the request goroutine to exit
|
|
// before returning an Edge-qualified error. It never leaves a send goroutine or a
|
|
// pending request behind. A closed client is removed by the token-fenced
|
|
// disconnect listener.
|
|
func (s *EdgeServer) pushProjection(ctx context.Context, target projectionClient, projection *iop.PrincipalProjection, pushTimeout time.Duration) error {
|
|
result := make(chan error, 1)
|
|
go func() {
|
|
resp, sendErr := proto_socket.SendRequestTyped[*iop.PrincipalProjectionApplyRequest, *iop.PrincipalProjectionApplyResponse](
|
|
&target.client.Communicator, &iop.PrincipalProjectionApplyRequest{Projection: projection}, pushTimeout)
|
|
switch {
|
|
case sendErr != nil:
|
|
result <- fmt.Errorf("push credential projection to edge %q: %w", target.edgeID, sendErr)
|
|
case !resp.GetApplied():
|
|
result <- fmt.Errorf("push credential projection to edge %q rejected", target.edgeID)
|
|
default:
|
|
result <- nil
|
|
}
|
|
}()
|
|
|
|
timer := time.NewTimer(pushTimeout)
|
|
defer timer.Stop()
|
|
|
|
select {
|
|
case err := <-result:
|
|
return err
|
|
case <-ctx.Done():
|
|
_ = target.client.Close()
|
|
<-result
|
|
return fmt.Errorf("push credential projection to edge %q: %w", target.edgeID, ctx.Err())
|
|
case <-timer.C:
|
|
_ = target.client.Close()
|
|
<-result
|
|
return fmt.Errorf("push credential projection to edge %q: projection push timed out after %s", target.edgeID, pushTimeout)
|
|
}
|
|
}
|
|
|
|
// setActiveClient records the live proto-socket client for a connected Edge so
|
|
// the Control Plane can send typed requests back to it. A newer connection
|
|
// always supersedes an older one for the same edge_id.
|
|
func (s *EdgeServer) setActiveClient(edgeID string, token uint64, client *proto_socket.TcpClient) {
|
|
s.activeMu.Lock()
|
|
defer s.activeMu.Unlock()
|
|
s.activeClients[edgeID] = activeEdgeClient{client: client, token: token}
|
|
}
|
|
|
|
// clearActiveClient removes the active client for an Edge, but only when token
|
|
// still matches the tracked connection so a stale disconnect cannot evict a
|
|
// newer reconnection.
|
|
func (s *EdgeServer) clearActiveClient(edgeID string, token uint64) {
|
|
s.activeMu.Lock()
|
|
defer s.activeMu.Unlock()
|
|
if entry, ok := s.activeClients[edgeID]; ok && entry.token == token {
|
|
delete(s.activeClients, edgeID)
|
|
}
|
|
}
|
|
|
|
// activeClient returns the live proto-socket client for a connected Edge.
|
|
func (s *EdgeServer) activeClient(edgeID string) (*proto_socket.TcpClient, bool) {
|
|
s.activeMu.Lock()
|
|
defer s.activeMu.Unlock()
|
|
entry, ok := s.activeClients[edgeID]
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
return entry.client, true
|
|
}
|
|
|
|
// RequestStatus asks a connected Edge for its Edge-owned node snapshot over the
|
|
// wire and returns the typed response. It observes only what the Edge reports;
|
|
// it does not connect to or schedule Node directly. It fails when the Edge is
|
|
// not currently connected.
|
|
func (s *EdgeServer) RequestStatus(edgeID string, timeout time.Duration) (*iop.EdgeStatusResponse, error) {
|
|
client, ok := s.activeClient(edgeID)
|
|
if !ok {
|
|
return nil, fmt.Errorf("edge %q is not connected", edgeID)
|
|
}
|
|
return proto_socket.SendRequestTyped[*iop.EdgeStatusRequest, *iop.EdgeStatusResponse](
|
|
&client.Communicator,
|
|
&iop.EdgeStatusRequest{RequestId: fmt.Sprintf("edge-status-%d", time.Now().UnixNano())},
|
|
timeout,
|
|
)
|
|
}
|
|
|
|
// SendCommand sends a command request to a connected Edge over the wire, records
|
|
// the request and result in the audit registry, and returns the Edge's typed
|
|
// response. It routes only to the target Edge's active connection and fails when
|
|
// that Edge is not currently connected. Command lifecycle events the Edge relays
|
|
// during execution are recorded asynchronously by the connection's event
|
|
// listener. It observes and controls through the Edge; it does not reach Node
|
|
// directly.
|
|
func (s *EdgeServer) SendCommand(edgeID, operation, targetSelector string, parameters map[string]string, timeout time.Duration) (*iop.EdgeCommandResponse, error) {
|
|
client, ok := s.activeClient(edgeID)
|
|
if !ok {
|
|
return nil, fmt.Errorf("edge %q is not connected", edgeID)
|
|
}
|
|
now := time.Now()
|
|
req := &iop.EdgeCommandRequest{
|
|
RequestId: fmt.Sprintf("edge-command-%d", now.UnixNano()),
|
|
CommandId: fmt.Sprintf("cmd-%d", now.UnixNano()),
|
|
Operation: operation,
|
|
TargetSelector: targetSelector,
|
|
Parameters: parameters,
|
|
}
|
|
s.registry.RecordCommandRequest(edgeID, req, now)
|
|
|
|
resp, err := proto_socket.SendRequestTyped[*iop.EdgeCommandRequest, *iop.EdgeCommandResponse](
|
|
&client.Communicator,
|
|
req,
|
|
timeout,
|
|
)
|
|
if err != nil {
|
|
s.registry.RecordCommandResult(edgeID, &iop.EdgeCommandResponse{
|
|
CommandId: req.GetCommandId(),
|
|
EdgeId: edgeID,
|
|
Status: "error",
|
|
Error: err.Error(),
|
|
}, time.Now())
|
|
return nil, err
|
|
}
|
|
if resp.GetCommandId() == "" {
|
|
resp.CommandId = req.GetCommandId()
|
|
}
|
|
if resp.GetEdgeId() == "" {
|
|
resp.EdgeId = edgeID
|
|
}
|
|
s.registry.RecordCommandResult(edgeID, resp, time.Now())
|
|
return resp, nil
|
|
}
|
|
|
|
// Host returns the host the server is listening on.
|
|
func (s *EdgeServer) Host() string { return s.host }
|
|
|
|
// Port returns the port the server is listening on.
|
|
func (s *EdgeServer) Port() int { return s.port }
|