Files
zeekay 3da6271eaf zapclient: native ZAP RPC with procedure-name dispatch — replaces clienthttp
clienthttp blended ZAP with HTTP by sitting on top of zap-proto/http
(http.RoundTripper). That mixed two protocols that should stay
separate: ZAP is its own binary wire, HTTP is for the public Ingress
edge and browser clients. zapclient is the canonical native ZAP
service-to-service path.

Decomplected into orthogonal pieces:

  - Discovery — peer enumeration. Default: luxfi/mdns adapter.
    Swap via WithDiscovery for Consul/etcd/static.
  - Picker — per-call peer selection. Default: round-robin. Swap
    via WithPicker for weighted/locality-aware/sticky.
  - PeerVerifier — server-side authorisation. Default:
    LocalTrustVerifier (accepts any mTLS-authenticated cluster
    peer). AllowListVerifier offered for per-procedure NodeID
    allow lists; custom impls plug in via WithVerifier.
  - Procedure codec — FNV-1a name → uint16 opcode with reserved-
    range avoidance (0x00 + 0xff). Registration refuses opcode
    collisions; callers rename until distinct.

Client API:

  c, _ := zapclient.Connect(ctx, "liquidity-ta", zapclient.WithTLS(...))
  defer c.Close()
  resp, err := c.Call(ctx, "ListSecurity", req)
  c.Send(ctx, "AuditEvent", evt)
  c.Broadcast(ctx, "Shutdown", nil)

Server API:

  srv, _ := zapclient.NewServer("liquidity-ta", zapclient.WithServerTLS(...))
  srv.Register("ListSecurity", handler)
  srv.Start(); defer srv.Stop()

ARCHITECTURE.md and SECURITY.md document the trust model: cluster
CA mTLS is the load-bearing root; LocalTrust is the default verifier
for in-cluster peers; AllowList provides explicit per-procedure
RBAC. HTTP/SSE/WS stay for browser + external clients at the
Ingress edge.
2026-05-13 09:49:37 -07:00

161 lines
5.5 KiB
Go

// Package zapclient — native ZAP client + server with mDNS discovery.
//
// ZAP is its own binary protocol — NOT layered on HTTP. This package
// wraps lux/zap's Node with procedure-name dispatch and discovery
// helpers so service-to-service callers can write:
//
// client := zapclient.MustConnect(ctx, "liquidity-ta")
// defer client.Close()
// resp, err := client.Call(ctx, "ListSecurity", req)
//
// instead of the lower-level (opcode, peerID, *zap.Message) loop.
//
// For browser/web clients use zapsse (separate package): ZAP over
// Server-Sent Events. Optional WebSocket support is offered when SSE
// is unsuitable. HTTP/REST is reserved for the public-edge Ingress.
package zapclient
import (
"context"
"errors"
"time"
)
// Sentinel errors. Callers branch via errors.Is.
var (
// ErrNoPeers is returned when discovery has no live peer for the
// requested service type.
ErrNoPeers = errors.New("zapclient: no peers discovered")
// ErrUnknownProcedure is returned by the server when an incoming
// opcode does not match any Register call.
ErrUnknownProcedure = errors.New("zapclient: unknown procedure")
// ErrPeerUnauthorized is returned when PeerVerifier refuses the
// incoming peer. Maps to a transport-layer reject.
ErrPeerUnauthorized = errors.New("zapclient: peer unauthorized")
// ErrCallTimeout is returned by Call when the per-call deadline
// elapses before the response arrives.
ErrCallTimeout = errors.New("zapclient: call timed out")
)
// Peer is the discovery view of one reachable instance of a service.
//
// NodeID uniquely identifies the peer within ServiceType across
// discovery cycles. Address is the dial target (host:port). For
// in-cluster peers under mTLS, the authenticated peer identity comes
// from the TLS session — NodeID is a hint, not a trust anchor.
type Peer struct {
NodeID string
ServiceType string
Address string
Metadata map[string]string
LastSeen time.Time
}
// Discovery is the peer-enumeration contract.
//
// Implementations MUST be goroutine-safe; the client may call Peers()
// from every in-flight Call and Send.
type Discovery interface {
// Peers returns the current peer snapshot. The returned slice is
// freshly allocated; callers may retain it.
Peers() []Peer
// PeerCount returns len(Peers()) cheaply.
PeerCount() int
// ServiceType reports the service the Discovery is browsing.
ServiceType() string
// Start begins the discovery loop. Calling Start twice on the
// same Discovery is a programmer error.
Start() error
// Stop releases all resources. Idempotent.
Stop()
}
// Picker selects one peer from a Discovery snapshot for a single
// Call or Send. Implementations MUST be cheap (sub-microsecond) and
// goroutine-safe; the client invokes Pick from every concurrent op.
type Picker interface {
// Pick returns the chosen peer. ErrNoPeers if peers is empty.
Pick(peers []Peer) (Peer, error)
}
// PeerVerifier is the server-side authorisation hook. Runs on every
// inbound message before procedure dispatch — return non-nil to
// reject the call.
//
// The PeerInfo carries the authenticated peer identity (NodeID +
// mTLS cert chain if present). Procedure is the procedure name the
// caller requested. PeerVerifier should compare the (peer, procedure)
// pair against the service's authorisation policy.
type PeerVerifier interface {
// Verify returns nil on accept, ErrPeerUnauthorized (or wrapping
// error) on reject. Implementations MUST NOT block — refer to
// pre-built decision tables, not network calls.
Verify(ctx context.Context, peer PeerInfo, procedure string) error
}
// PeerInfo is the authenticated peer identity surfaced to server-side
// handlers and PeerVerifier.
type PeerInfo struct {
NodeID string
ServiceType string
// TLSCertSubject is the Subject CN of the mTLS peer cert, when
// the listener was configured with TLS. Empty under plaintext
// (local-dev) transport.
TLSCertSubject string
// TLSCertSANs are the Subject Alternative Names from the peer
// cert. Authorisation should prefer SANs over the legacy CN.
TLSCertSANs []string
}
// LocalTrustVerifier accepts any peer presenting a valid mTLS cert
// from the cluster CA. The underlying TLS handshake already verified
// the chain; this verifier returns nil for every authenticated peer.
//
// Use when:
// - the peer is on the cluster-private network
// - the listener was constructed with the cluster CA in ClientCAs
// - all in-cluster services should be allowed to call all
// procedures (apply per-procedure RBAC in the handler if needed)
type LocalTrustVerifier struct{}
// Verify accepts any authenticated peer.
func (LocalTrustVerifier) Verify(ctx context.Context, peer PeerInfo, procedure string) error {
if peer.NodeID == "" && peer.TLSCertSubject == "" {
// Plaintext + no NodeID is a programmer error — reject.
return ErrPeerUnauthorized
}
return nil
}
// AllowListVerifier accepts only peers whose NodeID appears in
// Allowed[procedure]. Empty Allowed[procedure] means the procedure is
// not exposed.
type AllowListVerifier struct {
// Allowed maps procedure name → set of NodeIDs permitted to call
// it. Empty set = procedure disabled.
Allowed map[string]map[string]struct{}
}
// Verify accepts if NodeID is in Allowed[procedure].
func (a AllowListVerifier) Verify(ctx context.Context, peer PeerInfo, procedure string) error {
if a.Allowed == nil {
return ErrPeerUnauthorized
}
set, ok := a.Allowed[procedure]
if !ok || len(set) == 0 {
return ErrPeerUnauthorized
}
if _, ok := set[peer.NodeID]; ok {
return nil
}
return ErrPeerUnauthorized
}