mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -10:00
Update the Go module path, imports, install scripts, docs, workflows, packaging and action defaults from Gitlawb/zero to Twigpine/zero. The npm scope (already @twigpine/zero on main, with its @gitlawb/zero migration notes), LICENSE, action author, the OpenGateway provider and historical CHANGELOG links are unchanged. The X-AIMLAPI-Integration-Repo header keeps Gitlawb/zero: it is what AIMLAPI sees for attribution, so changing it needs a maintainer's yes. docs/INSTALL.md keeps `go install github.com/Gitlawb/zero/...`, because every tagged release so far declares that module path. The Homebrew formula comment names the Twigpine/homebrew-tap tap. Co-authored-by: Claude Opus 5.5 <[email protected]>
1235 lines
35 KiB
Go
1235 lines
35 KiB
Go
package peermsg
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode"
|
|
|
|
"github.com/Twigpine/zero/internal/fsutil"
|
|
)
|
|
|
|
type Handler func(InboundMessage) bool
|
|
|
|
type StatusHandler func(StatusEvent)
|
|
|
|
type HeldEvictionHandler func(string)
|
|
|
|
type HeldReleaseHandler func(InboundMessage)
|
|
|
|
const (
|
|
peerBucketCapacity = 30.0
|
|
peerRefillPerSecond = 0.5
|
|
peerDedupWindow = 30 * time.Second
|
|
peerMaxSelfHops = 10
|
|
peerMaxChainLength = 28
|
|
peerMaxTrackedSenders = 256
|
|
peerMaxHeldMessages = 100
|
|
peerMaxOutstanding = 256
|
|
peerOutstandingTTL = 6 * time.Minute
|
|
peerProbeWorkers = 8
|
|
peerProbeTimeout = 300 * time.Millisecond
|
|
peerInboundWorkers = 32
|
|
peerReceiptWorkers = peerMaxHeldMessages
|
|
peerReceiptTimeout = 750 * time.Millisecond
|
|
peerRegistryCacheTTL = 250 * time.Millisecond
|
|
peerFrameTimeout = 5 * time.Second
|
|
peerRefHexLength = 12
|
|
peerErrorMaxRunes = 512
|
|
)
|
|
|
|
type inboundContextKey struct{}
|
|
|
|
type senderGuard struct {
|
|
tokens float64
|
|
lastRefill time.Time
|
|
lastBody string
|
|
lastBodyAt time.Time
|
|
lastActivity time.Time
|
|
}
|
|
|
|
type outstandingMessage struct {
|
|
peer Peer
|
|
createdAt time.Time
|
|
}
|
|
|
|
type Options struct {
|
|
RootDir string
|
|
Identity Identity
|
|
Now func() time.Time
|
|
Transport localTransport
|
|
PID int
|
|
InboundPolicy InboundPolicy
|
|
}
|
|
|
|
// Service owns one live peer endpoint plus the registry used to discover other
|
|
// local Zero sessions. The transport is platform-specific, while framing,
|
|
// identity resolution, limits, and delivery policy remain shared.
|
|
type Service struct {
|
|
mu sync.RWMutex
|
|
root string
|
|
identity Identity
|
|
now func() time.Time
|
|
transport localTransport
|
|
pid int
|
|
nonce string
|
|
self Peer
|
|
listener net.Listener
|
|
handler Handler
|
|
statusHandler StatusHandler
|
|
evictionHandler HeldEvictionHandler
|
|
releaseHandler HeldReleaseHandler
|
|
policy InboundPolicy
|
|
outstanding map[string]outstandingMessage
|
|
held map[string]InboundMessage
|
|
heldOrder []string
|
|
guards map[string]*senderGuard
|
|
closed bool
|
|
wg sync.WaitGroup
|
|
done chan struct{}
|
|
inboundSlots chan struct{}
|
|
registryMu sync.Mutex
|
|
registryCache []Peer
|
|
registryModTime time.Time
|
|
registryCached time.Time
|
|
registryValid bool
|
|
}
|
|
|
|
func New(options Options) (*Service, error) {
|
|
root := strings.TrimSpace(options.RootDir)
|
|
if root == "" {
|
|
var err error
|
|
root, err = DefaultRoot()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
abs, err := filepath.Abs(root)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("peer messaging: resolve runtime directory: %w", err)
|
|
}
|
|
abs, err = canonicalRuntimePath(abs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("peer messaging: canonicalize runtime directory: %w", err)
|
|
}
|
|
nonce, err := randomHex(8)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("peer messaging: generate endpoint id: %w", err)
|
|
}
|
|
now := options.Now
|
|
if now == nil {
|
|
now = time.Now
|
|
}
|
|
transport := options.Transport
|
|
if transport == nil {
|
|
transport = platformTransport()
|
|
}
|
|
pid := options.PID
|
|
if pid <= 0 {
|
|
pid = os.Getpid()
|
|
}
|
|
return &Service{
|
|
root: abs,
|
|
identity: normalizeIdentity(options.Identity),
|
|
now: now,
|
|
transport: transport,
|
|
pid: pid,
|
|
nonce: nonce,
|
|
policy: normalizeInboundPolicy(options.InboundPolicy),
|
|
outstanding: make(map[string]outstandingMessage),
|
|
held: make(map[string]InboundMessage),
|
|
guards: make(map[string]*senderGuard),
|
|
done: make(chan struct{}),
|
|
inboundSlots: make(chan struct{}, peerInboundWorkers),
|
|
}, nil
|
|
}
|
|
|
|
// canonicalRuntimePath normalizes aliases in the existing prefix. It does not
|
|
// establish a security boundary; ensurePrivateDir validates that boundary when
|
|
// the service starts.
|
|
func canonicalRuntimePath(path string) (string, error) {
|
|
missing := make([]string, 0, 4)
|
|
existing := filepath.Clean(path)
|
|
for {
|
|
_, err := os.Lstat(existing)
|
|
if err == nil {
|
|
break
|
|
}
|
|
if !errors.Is(err, os.ErrNotExist) {
|
|
return "", err
|
|
}
|
|
parent := filepath.Dir(existing)
|
|
if parent == existing {
|
|
return "", err
|
|
}
|
|
missing = append(missing, filepath.Base(existing))
|
|
existing = parent
|
|
}
|
|
resolved, err := filepath.EvalSymlinks(existing)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for index := len(missing) - 1; index >= 0; index-- {
|
|
resolved = filepath.Join(resolved, missing[index])
|
|
}
|
|
return resolved, nil
|
|
}
|
|
|
|
// WithInboundMessage carries a peer message's relay chain through the agent
|
|
// turn so a deliberate send_message reply or forward can extend it. Ordinary
|
|
// user turns have no chain and start a fresh one.
|
|
func WithInboundMessage(ctx context.Context, message InboundMessage) context.Context {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
chain := append([]string(nil), message.HopChain...)
|
|
return context.WithValue(ctx, inboundContextKey{}, chain)
|
|
}
|
|
|
|
func DefaultRoot() (string, error) {
|
|
if runtimeDir := strings.TrimSpace(os.Getenv("XDG_RUNTIME_DIR")); runtimeDir != "" {
|
|
return filepath.Join(runtimeDir, "zero", "peers"), nil
|
|
}
|
|
cacheDir, err := os.UserCacheDir()
|
|
if err != nil {
|
|
return "", fmt.Errorf("peer messaging: resolve user cache directory: %w", err)
|
|
}
|
|
return filepath.Join(cacheDir, "zero", "peers"), nil
|
|
}
|
|
|
|
func (service *Service) Start(handler Handler) error {
|
|
service.mu.Lock()
|
|
defer service.mu.Unlock()
|
|
if service.listener != nil {
|
|
return nil
|
|
}
|
|
if service.closed {
|
|
return errors.New("peer messaging: service is closed")
|
|
}
|
|
if err := ensurePrivateDir(service.root); err != nil {
|
|
return fmt.Errorf("peer messaging: create runtime directory: %w", err)
|
|
}
|
|
if err := ensurePrivateDir(service.registryDir()); err != nil {
|
|
return fmt.Errorf("peer messaging: create registry: %w", err)
|
|
}
|
|
endpoint, err := service.transport.Endpoint(service.root, service.nonce, service.pid)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
listener, err := service.transport.Listen(endpoint)
|
|
if err != nil {
|
|
return fmt.Errorf("peer messaging: listen: %w", err)
|
|
}
|
|
now := service.now().UTC()
|
|
if service.identity.SessionID == "" {
|
|
service.identity.SessionID = "live-" + service.nonce
|
|
}
|
|
service.self = Peer{
|
|
Identity: service.identity,
|
|
Endpoint: endpoint,
|
|
PID: service.pid,
|
|
StartedAt: now,
|
|
UpdatedAt: now,
|
|
Ref: peerRef(endpoint),
|
|
}
|
|
service.listener = listener
|
|
service.handler = handler
|
|
if err := service.writeRecordLocked(); err != nil {
|
|
service.listener = nil
|
|
return errors.Join(err, listener.Close(), service.transport.Remove(endpoint))
|
|
}
|
|
service.wg.Add(1)
|
|
go service.acceptLoop(listener)
|
|
return nil
|
|
}
|
|
|
|
func (service *Service) SetStatusHandler(handler StatusHandler) {
|
|
service.mu.Lock()
|
|
defer service.mu.Unlock()
|
|
service.statusHandler = handler
|
|
}
|
|
|
|
func (service *Service) SetHeldEvictionHandler(handler HeldEvictionHandler) {
|
|
service.mu.Lock()
|
|
defer service.mu.Unlock()
|
|
service.evictionHandler = handler
|
|
}
|
|
|
|
func (service *Service) SetHeldReleaseHandler(handler HeldReleaseHandler) {
|
|
service.mu.Lock()
|
|
defer service.mu.Unlock()
|
|
service.releaseHandler = handler
|
|
}
|
|
|
|
func (service *Service) Close() error {
|
|
service.mu.Lock()
|
|
if service.closed {
|
|
service.mu.Unlock()
|
|
return nil
|
|
}
|
|
service.closed = true
|
|
close(service.done)
|
|
listener := service.listener
|
|
endpoint := service.self.Endpoint
|
|
held := make([]InboundMessage, 0, len(service.held))
|
|
for _, message := range service.held {
|
|
held = append(held, message)
|
|
}
|
|
service.held = make(map[string]InboundMessage)
|
|
service.heldOrder = nil
|
|
service.outstanding = make(map[string]outstandingMessage)
|
|
service.listener = nil
|
|
service.mu.Unlock()
|
|
|
|
service.sendStatuses(held, DeliveryExpired)
|
|
|
|
var closeErrs []error
|
|
if listener != nil {
|
|
if err := listener.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
|
|
closeErrs = append(closeErrs, err)
|
|
}
|
|
}
|
|
service.wg.Wait()
|
|
if endpoint != "" {
|
|
if err := service.transport.Remove(endpoint); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
closeErrs = append(closeErrs, err)
|
|
}
|
|
}
|
|
if err := service.removeOwnRecord(endpoint); err != nil {
|
|
closeErrs = append(closeErrs, err)
|
|
}
|
|
return errors.Join(closeErrs...)
|
|
}
|
|
|
|
// ResolveHeld settles a message that was parked for local approval and sends a
|
|
// terminal receipt back to its sender. Repeated or stale decisions are ignored.
|
|
func (service *Service) ResolveHeld(ctx context.Context, messageID string, status DeliveryStatus) error {
|
|
if status != DeliveryDelivered && status != DeliveryDenied && status != DeliveryExpired {
|
|
return fmt.Errorf("peer messaging: invalid held-message status %q", status)
|
|
}
|
|
service.mu.Lock()
|
|
message, ok := service.held[messageID]
|
|
if ok {
|
|
delete(service.held, messageID)
|
|
service.removeHeldOrderLocked(messageID)
|
|
}
|
|
service.mu.Unlock()
|
|
if !ok {
|
|
return nil
|
|
}
|
|
return service.sendStatus(ctx, message, status)
|
|
}
|
|
|
|
func (service *Service) UpdateIdentity(identity Identity) error {
|
|
service.mu.Lock()
|
|
service.identity = normalizeIdentity(identity)
|
|
if service.listener == nil {
|
|
service.mu.Unlock()
|
|
return nil
|
|
}
|
|
service.self.Identity = service.identity
|
|
service.self.UpdatedAt = service.now().UTC()
|
|
if err := service.writeRecordLocked(); err != nil {
|
|
service.mu.Unlock()
|
|
return err
|
|
}
|
|
released := make([]InboundMessage, 0)
|
|
if service.policy == InboundPolicyParity && service.releaseHandler != nil {
|
|
for _, messageID := range append([]string(nil), service.heldOrder...) {
|
|
message, ok := service.held[messageID]
|
|
if !ok {
|
|
service.removeHeldOrderLocked(messageID)
|
|
continue
|
|
}
|
|
status, _ := service.inboundDecision(message.From.PermissionClass, service.self.PermissionClass)
|
|
if status != DeliveryAccepted {
|
|
continue
|
|
}
|
|
delete(service.held, messageID)
|
|
service.removeHeldOrderLocked(messageID)
|
|
message.RequiresApproval = false
|
|
message.HoldCause = ""
|
|
released = append(released, message)
|
|
}
|
|
}
|
|
releaseHandler := service.releaseHandler
|
|
service.mu.Unlock()
|
|
for _, message := range released {
|
|
if releaseHandler != nil {
|
|
releaseHandler(message)
|
|
}
|
|
}
|
|
if len(released) > 0 {
|
|
service.launchStatuses(released, DeliveryDelivered)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (service *Service) Self() Peer {
|
|
service.mu.RLock()
|
|
defer service.mu.RUnlock()
|
|
return service.self
|
|
}
|
|
|
|
func (service *Service) List(ctx context.Context) ([]Peer, error) {
|
|
candidates, err := service.registryPeers()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
self := service.Self()
|
|
type probeResult struct {
|
|
peer Peer
|
|
reachable bool
|
|
}
|
|
jobs := make(chan Peer)
|
|
results := make(chan probeResult, len(candidates))
|
|
workers := min(peerProbeWorkers, len(candidates))
|
|
var wg sync.WaitGroup
|
|
for range workers {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for peer := range jobs {
|
|
if peer.Endpoint == self.Endpoint || peer.SessionID == "" {
|
|
continue
|
|
}
|
|
probeCtx, cancel := context.WithTimeout(ctx, peerProbeTimeout)
|
|
conn, dialErr := service.transport.Dial(probeCtx, peer.Endpoint)
|
|
cancel()
|
|
if dialErr == nil {
|
|
_ = conn.Close()
|
|
}
|
|
results <- probeResult{peer: peer, reachable: dialErr == nil}
|
|
}
|
|
}()
|
|
}
|
|
go func() {
|
|
defer close(jobs)
|
|
for _, peer := range candidates {
|
|
select {
|
|
case jobs <- peer:
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
go func() { wg.Wait(); close(results) }()
|
|
peers := make([]Peer, 0, len(candidates))
|
|
for result := range results {
|
|
if result.reachable {
|
|
peers = append(peers, result.peer)
|
|
}
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, fmt.Errorf("peer messaging: list sessions: %w", err)
|
|
}
|
|
sort.Slice(peers, func(i, j int) bool {
|
|
if strings.EqualFold(peers[i].Name, peers[j].Name) {
|
|
return peers[i].Ref < peers[j].Ref
|
|
}
|
|
return strings.ToLower(peers[i].Name) < strings.ToLower(peers[j].Name)
|
|
})
|
|
return peers, nil
|
|
}
|
|
|
|
func (service *Service) registryPeers() ([]Peer, error) {
|
|
service.registryMu.Lock()
|
|
defer service.registryMu.Unlock()
|
|
|
|
info, statErr := os.Stat(service.registryDir())
|
|
if errors.Is(statErr, os.ErrNotExist) {
|
|
service.registryCache = nil
|
|
service.registryModTime = time.Time{}
|
|
service.registryCached = service.now()
|
|
service.registryValid = true
|
|
return nil, nil
|
|
}
|
|
if statErr != nil {
|
|
return nil, fmt.Errorf("peer messaging: inspect registry: %w", statErr)
|
|
}
|
|
now := service.now()
|
|
if service.registryValid && info.ModTime().Equal(service.registryModTime) && now.Sub(service.registryCached) < peerRegistryCacheTTL {
|
|
return append([]Peer(nil), service.registryCache...), nil
|
|
}
|
|
entries, err := os.ReadDir(service.registryDir())
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("peer messaging: read registry: %w", err)
|
|
}
|
|
peers := make([]Peer, 0, len(entries))
|
|
for _, entry := range entries {
|
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
|
|
continue
|
|
}
|
|
peer, readErr := readPeerRecord(filepath.Join(service.registryDir(), entry.Name()))
|
|
if readErr == nil && peer.Endpoint != "" && peer.SessionID != "" {
|
|
peers = append(peers, peer)
|
|
}
|
|
}
|
|
service.registryCache = append(service.registryCache[:0], peers...)
|
|
service.registryModTime = info.ModTime()
|
|
service.registryCached = now
|
|
service.registryValid = true
|
|
return append([]Peer(nil), peers...), nil
|
|
}
|
|
|
|
// registeredPeer replaces all sender-controlled identity metadata with the
|
|
// record published by the live service that owns the claimed endpoint.
|
|
func (service *Service) registeredPeer(claimed Peer) (Peer, error) {
|
|
peers, err := service.registryPeers()
|
|
if err != nil {
|
|
return Peer{}, err
|
|
}
|
|
for _, peer := range peers {
|
|
if peer.Endpoint == claimed.Endpoint && peer.SessionID == claimed.SessionID && peer.Ref == claimed.Ref {
|
|
return peer, nil
|
|
}
|
|
}
|
|
return Peer{}, errors.New("peer messaging: unregistered sender")
|
|
}
|
|
|
|
func (service *Service) Send(ctx context.Context, to, summary, body string) (SendResult, error) {
|
|
body = normalizeBody(body)
|
|
summary = normalizeSummary(summary)
|
|
if body == "" {
|
|
return SendResult{}, errors.New("peer messaging: message must not be empty")
|
|
}
|
|
if len(body) > maxMessageBytes {
|
|
return SendResult{}, fmt.Errorf("peer messaging: message exceeds %d bytes", maxMessageBytes)
|
|
}
|
|
if summary == "" {
|
|
return SendResult{}, errors.New("peer messaging: summary is required")
|
|
}
|
|
peers, err := service.List(ctx)
|
|
if err != nil {
|
|
return SendResult{}, err
|
|
}
|
|
peer, err := resolvePeer(peers, to)
|
|
if err != nil {
|
|
return SendResult{}, err
|
|
}
|
|
id, err := randomHex(16)
|
|
if err != nil {
|
|
return SendResult{}, fmt.Errorf("peer messaging: generate message id: %w", err)
|
|
}
|
|
self := service.Self()
|
|
hopChain := inboundHopChain(ctx)
|
|
if len(hopChain) == 0 || hopChain[len(hopChain)-1] != self.Ref {
|
|
hopChain = append(hopChain, self.Ref)
|
|
}
|
|
if err := validateHopChain(hopChain); err != nil {
|
|
return SendResult{}, err
|
|
}
|
|
frame := sendFrame{
|
|
Version: ProtocolVersion,
|
|
Type: "message",
|
|
ID: id,
|
|
From: self,
|
|
To: peer.SessionID,
|
|
Summary: summary,
|
|
Body: body,
|
|
HopChain: hopChain,
|
|
}
|
|
service.mu.Lock()
|
|
service.pruneOutstandingLocked(service.now())
|
|
service.outstanding[id] = outstandingMessage{peer: peer, createdAt: service.now()}
|
|
service.mu.Unlock()
|
|
keepOutstanding := false
|
|
defer func() {
|
|
if keepOutstanding {
|
|
return
|
|
}
|
|
service.mu.Lock()
|
|
delete(service.outstanding, id)
|
|
service.mu.Unlock()
|
|
}()
|
|
conn, err := service.transport.Dial(ctx, peer.Endpoint)
|
|
if err != nil {
|
|
return SendResult{}, fmt.Errorf("peer messaging: connect to %s: %w", displayPeer(peer), err)
|
|
}
|
|
defer conn.Close()
|
|
_ = conn.SetDeadline(deadlineFromContext(ctx, peerFrameTimeout))
|
|
if err := json.NewEncoder(conn).Encode(frame); err != nil {
|
|
return SendResult{}, fmt.Errorf("peer messaging: send to %s: %w", displayPeer(peer), err)
|
|
}
|
|
var response responseFrame
|
|
if err := decodeFrame(conn, &response); err != nil {
|
|
return SendResult{}, fmt.Errorf("peer messaging: receive delivery status from %s: %w", displayPeer(peer), err)
|
|
}
|
|
if response.Version != ProtocolVersion || response.Type != "delivery" || response.ID != id {
|
|
return SendResult{}, errors.New("peer messaging: invalid delivery response")
|
|
}
|
|
if response.Error != "" {
|
|
return SendResult{}, fmt.Errorf("peer messaging: receiver rejected message: %s", normalizeRemoteError(response.Error))
|
|
}
|
|
if response.Status != DeliveryAccepted && response.Status != DeliveryHeld && response.Status != DeliveryRefused {
|
|
return SendResult{}, errors.New("peer messaging: invalid delivery status")
|
|
}
|
|
keepOutstanding = response.Status == DeliveryHeld
|
|
return SendResult{MessageID: id, Peer: peer, Status: response.Status}, nil
|
|
}
|
|
|
|
func (service *Service) acceptLoop(listener net.Listener) {
|
|
defer service.wg.Done()
|
|
var retryDelay time.Duration
|
|
for {
|
|
conn, err := listener.Accept()
|
|
if err != nil {
|
|
if errors.Is(err, net.ErrClosed) {
|
|
return
|
|
}
|
|
service.mu.RLock()
|
|
closed := service.closed
|
|
service.mu.RUnlock()
|
|
if closed {
|
|
return
|
|
}
|
|
if retryDelay == 0 {
|
|
retryDelay = 5 * time.Millisecond
|
|
} else {
|
|
retryDelay *= 2
|
|
}
|
|
retryDelay = min(retryDelay, time.Second)
|
|
timer := time.NewTimer(retryDelay)
|
|
select {
|
|
case <-timer.C:
|
|
case <-service.done:
|
|
if !timer.Stop() {
|
|
<-timer.C
|
|
}
|
|
return
|
|
}
|
|
continue
|
|
}
|
|
retryDelay = 0
|
|
select {
|
|
case service.inboundSlots <- struct{}{}:
|
|
default:
|
|
_ = conn.Close()
|
|
continue
|
|
}
|
|
service.wg.Add(1)
|
|
go func() {
|
|
defer service.wg.Done()
|
|
defer func() { <-service.inboundSlots }()
|
|
defer conn.Close()
|
|
service.handleConn(conn)
|
|
}()
|
|
}
|
|
}
|
|
|
|
func (service *Service) handleConn(conn net.Conn) {
|
|
_ = conn.SetDeadline(time.Now().Add(peerFrameTimeout))
|
|
var frame sendFrame
|
|
if err := decodeFrame(conn, &frame); err != nil {
|
|
if !errors.Is(err, io.EOF) {
|
|
_ = json.NewEncoder(conn).Encode(responseFrame{Version: ProtocolVersion, Type: "delivery", Status: DeliveryRefused, Error: "invalid peer message"})
|
|
}
|
|
return
|
|
}
|
|
if frame.Type == "status" {
|
|
service.handleStatusFrame(frame)
|
|
return
|
|
}
|
|
response := responseFrame{Version: ProtocolVersion, Type: "delivery", ID: frame.ID, Status: DeliveryRefused}
|
|
if frame.Version != ProtocolVersion || frame.Type != "message" || frame.ID == "" ||
|
|
frame.From.SessionID == "" || frame.From.Endpoint == "" || !validPeerRef(frame.From.Endpoint, frame.From.Ref) {
|
|
response.Error = "peer messaging: invalid message envelope"
|
|
_ = json.NewEncoder(conn).Encode(response)
|
|
return
|
|
}
|
|
trustedSender, err := service.registeredPeer(frame.From)
|
|
if err != nil {
|
|
response.Error = "peer messaging: sender identity is not registered"
|
|
_ = json.NewEncoder(conn).Encode(response)
|
|
return
|
|
}
|
|
frame.From = trustedSender
|
|
if len(frame.Body) == 0 || len(frame.Body) > maxMessageBytes {
|
|
response.Error = "peer messaging: invalid message size"
|
|
_ = json.NewEncoder(conn).Encode(response)
|
|
return
|
|
}
|
|
body := normalizeBody(frame.Body)
|
|
if body == "" {
|
|
response.Error = "peer messaging: invalid message content"
|
|
_ = json.NewEncoder(conn).Encode(response)
|
|
return
|
|
}
|
|
service.mu.RLock()
|
|
self := service.self
|
|
handler := service.handler
|
|
service.mu.RUnlock()
|
|
if self.SessionID == "" || frame.To != self.SessionID {
|
|
response.Error = "peer messaging: target session is no longer active"
|
|
_ = json.NewEncoder(conn).Encode(response)
|
|
return
|
|
}
|
|
if len(frame.HopChain) == 0 {
|
|
frame.HopChain = []string{frame.From.Ref}
|
|
}
|
|
if err := validateHopChain(frame.HopChain); err != nil || frame.HopChain[len(frame.HopChain)-1] != frame.From.Ref {
|
|
response.Error = "peer messaging: invalid relay chain"
|
|
_ = json.NewEncoder(conn).Encode(response)
|
|
return
|
|
}
|
|
if reason := service.admitMessage(frame.From, body, frame.HopChain, self.Ref); reason != "" {
|
|
response.Error = "peer messaging: message dropped: " + reason
|
|
_ = json.NewEncoder(conn).Encode(response)
|
|
return
|
|
}
|
|
if handler == nil {
|
|
response.Status = DeliveryRefused
|
|
response.Error = "peer messaging: receiving session is unavailable"
|
|
_ = json.NewEncoder(conn).Encode(response)
|
|
return
|
|
}
|
|
status, holdCause := service.inboundDecision(frame.From.PermissionClass, self.PermissionClass)
|
|
response.Status = status
|
|
if status == DeliveryRefused {
|
|
_ = json.NewEncoder(conn).Encode(response)
|
|
return
|
|
}
|
|
message := InboundMessage{
|
|
ID: frame.ID,
|
|
From: frame.From,
|
|
Body: body,
|
|
Summary: normalizeSummary(frame.Summary),
|
|
ReceivedAt: service.now().UTC(),
|
|
RequiresApproval: status == DeliveryHeld,
|
|
HoldCause: holdCause,
|
|
HopChain: append([]string(nil), frame.HopChain...),
|
|
}
|
|
if status == DeliveryHeld {
|
|
var evicted InboundMessage
|
|
var evictionHandler HeldEvictionHandler
|
|
service.mu.Lock()
|
|
if len(service.held) >= peerMaxHeldMessages {
|
|
evicted = service.popOldestHeldLocked()
|
|
evictionHandler = service.evictionHandler
|
|
}
|
|
service.held[message.ID] = message
|
|
service.heldOrder = append(service.heldOrder, message.ID)
|
|
service.mu.Unlock()
|
|
if evicted.ID != "" {
|
|
if evictionHandler != nil {
|
|
evictionHandler(evicted.ID)
|
|
}
|
|
service.launchStatuses([]InboundMessage{evicted}, DeliveryExpired)
|
|
}
|
|
}
|
|
_ = conn.SetDeadline(time.Now().Add(peerFrameTimeout))
|
|
if !handler(message) {
|
|
if status == DeliveryHeld {
|
|
service.mu.Lock()
|
|
delete(service.held, message.ID)
|
|
service.removeHeldOrderLocked(message.ID)
|
|
service.mu.Unlock()
|
|
}
|
|
response.Status = DeliveryRefused
|
|
response.Error = "peer messaging: message dropped: receiver queue is full or unavailable"
|
|
_ = json.NewEncoder(conn).Encode(response)
|
|
return
|
|
}
|
|
_ = json.NewEncoder(conn).Encode(response)
|
|
}
|
|
|
|
func (service *Service) handleStatusFrame(frame sendFrame) {
|
|
if frame.Version != ProtocolVersion || frame.OrigID == "" || frame.From.SessionID == "" ||
|
|
frame.From.Endpoint == "" || !validPeerRef(frame.From.Endpoint, frame.From.Ref) || !terminalDeliveryStatus(frame.Status) {
|
|
return
|
|
}
|
|
trustedSender, err := service.registeredPeer(frame.From)
|
|
if err != nil {
|
|
return
|
|
}
|
|
frame.From = trustedSender
|
|
service.mu.Lock()
|
|
self := service.self
|
|
outstanding, ok := service.outstanding[frame.OrigID]
|
|
if !ok || frame.To != self.SessionID || outstanding.peer.Endpoint != frame.From.Endpoint || outstanding.peer.SessionID != frame.From.SessionID {
|
|
service.mu.Unlock()
|
|
return
|
|
}
|
|
delete(service.outstanding, frame.OrigID)
|
|
handler := service.statusHandler
|
|
service.mu.Unlock()
|
|
if handler != nil {
|
|
handler(StatusEvent{MessageID: frame.OrigID, Peer: outstanding.peer, Status: frame.Status})
|
|
}
|
|
}
|
|
|
|
func (service *Service) sendStatus(ctx context.Context, message InboundMessage, status DeliveryStatus) error {
|
|
if !terminalDeliveryStatus(status) {
|
|
return fmt.Errorf("peer messaging: invalid delivery status %q", status)
|
|
}
|
|
target := message.From
|
|
if target.Endpoint == "" || target.SessionID == "" || !validPeerRef(target.Endpoint, target.Ref) {
|
|
return errors.New("peer messaging: original sender identity is invalid")
|
|
}
|
|
self := service.Self()
|
|
frame := sendFrame{
|
|
Version: ProtocolVersion,
|
|
Type: "status",
|
|
From: self,
|
|
To: target.SessionID,
|
|
OrigID: message.ID,
|
|
Status: status,
|
|
}
|
|
conn, err := service.transport.Dial(ctx, target.Endpoint)
|
|
if err != nil {
|
|
return fmt.Errorf("peer messaging: send status to %s: %w", displayPeer(target), err)
|
|
}
|
|
defer conn.Close()
|
|
_ = conn.SetDeadline(deadlineFromContext(ctx, peerFrameTimeout))
|
|
if err := json.NewEncoder(conn).Encode(frame); err != nil {
|
|
return fmt.Errorf("peer messaging: send status to %s: %w", displayPeer(target), err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (service *Service) sendStatuses(messages []InboundMessage, status DeliveryStatus) {
|
|
jobs := make(chan InboundMessage)
|
|
workers := min(peerReceiptWorkers, len(messages))
|
|
var wg sync.WaitGroup
|
|
for range workers {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for message := range jobs {
|
|
ctx, cancel := context.WithTimeout(context.Background(), peerReceiptTimeout)
|
|
_ = service.sendStatus(ctx, message, status)
|
|
cancel()
|
|
}
|
|
}()
|
|
}
|
|
for _, message := range messages {
|
|
jobs <- message
|
|
}
|
|
close(jobs)
|
|
wg.Wait()
|
|
}
|
|
|
|
func deadlineFromContext(ctx context.Context, maximum time.Duration) time.Time {
|
|
deadline := time.Now().Add(maximum)
|
|
if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(deadline) {
|
|
return contextDeadline
|
|
}
|
|
return deadline
|
|
}
|
|
|
|
func (service *Service) launchStatuses(messages []InboundMessage, status DeliveryStatus) {
|
|
service.mu.Lock()
|
|
if service.closed {
|
|
service.mu.Unlock()
|
|
return
|
|
}
|
|
service.wg.Add(1)
|
|
service.mu.Unlock()
|
|
go func() {
|
|
defer service.wg.Done()
|
|
service.sendStatuses(messages, status)
|
|
}()
|
|
}
|
|
|
|
func (service *Service) pruneOutstandingLocked(now time.Time) {
|
|
for id, pending := range service.outstanding {
|
|
if now.Sub(pending.createdAt) >= peerOutstandingTTL {
|
|
delete(service.outstanding, id)
|
|
}
|
|
}
|
|
for len(service.outstanding) >= peerMaxOutstanding {
|
|
var oldestID string
|
|
var oldest time.Time
|
|
for id, pending := range service.outstanding {
|
|
if oldestID == "" || pending.createdAt.Before(oldest) {
|
|
oldestID, oldest = id, pending.createdAt
|
|
}
|
|
}
|
|
delete(service.outstanding, oldestID)
|
|
}
|
|
}
|
|
|
|
func (service *Service) inboundDecision(sender, receiver PermissionClass) (DeliveryStatus, HoldCause) {
|
|
switch service.policy {
|
|
case InboundPolicyAccept:
|
|
return DeliveryAccepted, ""
|
|
case InboundPolicyHold:
|
|
return DeliveryHeld, HoldCauseExplicit
|
|
case InboundPolicyRefuse:
|
|
return DeliveryRefused, ""
|
|
default:
|
|
if sender == "" {
|
|
if receiver == PermissionBypass {
|
|
return DeliveryHeld, HoldCauseModeUnknown
|
|
}
|
|
return DeliveryAccepted, ""
|
|
}
|
|
if sender == receiver {
|
|
return DeliveryAccepted, ""
|
|
}
|
|
return DeliveryHeld, HoldCauseModeMismatch
|
|
}
|
|
}
|
|
|
|
func (service *Service) admitMessage(sender Peer, body string, chain []string, selfRef string) string {
|
|
if len(chain) > peerMaxChainLength {
|
|
return "relay chain is too long"
|
|
}
|
|
selfHops := 0
|
|
for _, ref := range chain {
|
|
if ref == selfRef {
|
|
selfHops++
|
|
}
|
|
}
|
|
if selfHops >= peerMaxSelfHops {
|
|
return "peer messaging loop detected"
|
|
}
|
|
now := service.now()
|
|
key := sender.Endpoint
|
|
service.mu.Lock()
|
|
defer service.mu.Unlock()
|
|
guard := service.guards[key]
|
|
if guard == nil {
|
|
if len(service.guards) >= peerMaxTrackedSenders {
|
|
var oldestKey string
|
|
var oldest time.Time
|
|
for candidate, state := range service.guards {
|
|
if oldestKey == "" || state.lastActivity.Before(oldest) {
|
|
oldestKey, oldest = candidate, state.lastActivity
|
|
}
|
|
}
|
|
delete(service.guards, oldestKey)
|
|
}
|
|
guard = &senderGuard{tokens: peerBucketCapacity, lastRefill: now}
|
|
service.guards[key] = guard
|
|
}
|
|
elapsed := now.Sub(guard.lastRefill).Seconds()
|
|
if elapsed > 0 {
|
|
guard.tokens = min(peerBucketCapacity, guard.tokens+elapsed*peerRefillPerSecond)
|
|
guard.lastRefill = now
|
|
}
|
|
if guard.tokens < 1 {
|
|
return "sender exceeded the peer message rate limit"
|
|
}
|
|
guard.tokens--
|
|
if guard.lastBody == body && now.Sub(guard.lastBodyAt) < peerDedupWindow {
|
|
return "duplicate of a recent message from this sender"
|
|
}
|
|
guard.lastActivity = now
|
|
guard.lastBody = body
|
|
guard.lastBodyAt = now
|
|
return ""
|
|
}
|
|
|
|
func (service *Service) removeHeldOrderLocked(messageID string) {
|
|
for index, candidate := range service.heldOrder {
|
|
if candidate == messageID {
|
|
service.heldOrder = append(service.heldOrder[:index], service.heldOrder[index+1:]...)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (service *Service) popOldestHeldLocked() InboundMessage {
|
|
for len(service.heldOrder) > 0 {
|
|
messageID := service.heldOrder[0]
|
|
service.heldOrder = service.heldOrder[1:]
|
|
if message, ok := service.held[messageID]; ok {
|
|
delete(service.held, messageID)
|
|
return message
|
|
}
|
|
}
|
|
var oldestID string
|
|
var oldest InboundMessage
|
|
for messageID, message := range service.held {
|
|
if oldestID == "" || message.ReceivedAt.Before(oldest.ReceivedAt) {
|
|
oldestID, oldest = messageID, message
|
|
}
|
|
}
|
|
if oldestID != "" {
|
|
delete(service.held, oldestID)
|
|
}
|
|
return oldest
|
|
}
|
|
|
|
func inboundHopChain(ctx context.Context) []string {
|
|
if ctx == nil {
|
|
return nil
|
|
}
|
|
chain, _ := ctx.Value(inboundContextKey{}).([]string)
|
|
return append([]string(nil), chain...)
|
|
}
|
|
|
|
func validateHopChain(chain []string) error {
|
|
if len(chain) == 0 || len(chain) > peerMaxChainLength {
|
|
return errors.New("peer messaging: invalid relay chain")
|
|
}
|
|
for _, ref := range chain {
|
|
if len(ref) < 8 || len(ref) > peerRefHexLength || len(ref)%2 != 0 {
|
|
return errors.New("peer messaging: invalid relay chain")
|
|
}
|
|
if _, err := hex.DecodeString(ref); err != nil {
|
|
return errors.New("peer messaging: invalid relay chain")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func terminalDeliveryStatus(status DeliveryStatus) bool {
|
|
return status == DeliveryDelivered || status == DeliveryDenied || status == DeliveryExpired
|
|
}
|
|
|
|
func normalizeInboundPolicy(policy InboundPolicy) InboundPolicy {
|
|
switch policy {
|
|
case InboundPolicyAccept, InboundPolicyHold, InboundPolicyRefuse:
|
|
return policy
|
|
default:
|
|
return InboundPolicyParity
|
|
}
|
|
}
|
|
|
|
func decodeFrame(reader io.Reader, target any) error {
|
|
buffered := bufio.NewReader(io.LimitReader(reader, maxFrameBytes+1))
|
|
line, err := buffered.ReadBytes('\n')
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(line) > maxFrameBytes {
|
|
return errors.New("peer messaging: frame is too large")
|
|
}
|
|
if err := json.Unmarshal(line, target); err != nil {
|
|
return fmt.Errorf("peer messaging: decode frame: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (service *Service) registryDir() string { return filepath.Join(service.root, "registry") }
|
|
|
|
func (service *Service) recordPath() string {
|
|
return filepath.Join(service.registryDir(), fmt.Sprintf("%d-%s.json", service.pid, service.nonce))
|
|
}
|
|
|
|
func (service *Service) writeRecordLocked() error {
|
|
data, err := json.Marshal(service.self)
|
|
if err != nil {
|
|
return fmt.Errorf("peer messaging: encode registry record: %w", err)
|
|
}
|
|
path := service.recordPath()
|
|
tmp, err := os.CreateTemp(service.registryDir(), filepath.Base(path)+".*.tmp")
|
|
if err != nil {
|
|
return fmt.Errorf("peer messaging: create registry temporary file: %w", err)
|
|
}
|
|
tmpPath := tmp.Name()
|
|
defer os.Remove(tmpPath)
|
|
if err := tmp.Chmod(0o600); err != nil {
|
|
_ = tmp.Close()
|
|
return err
|
|
}
|
|
if _, err := tmp.Write(append(data, '\n')); err != nil {
|
|
_ = tmp.Close()
|
|
return err
|
|
}
|
|
if err := tmp.Sync(); err != nil {
|
|
_ = tmp.Close()
|
|
return err
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return err
|
|
}
|
|
if err := fsutil.RenameWithRetry(tmpPath, path, nil); err != nil {
|
|
return fmt.Errorf("peer messaging: publish registry record: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func readPeerRecord(path string) (Peer, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return Peer{}, err
|
|
}
|
|
var peer Peer
|
|
if err := json.Unmarshal(data, &peer); err != nil {
|
|
return Peer{}, err
|
|
}
|
|
if peer.PID <= 0 || peer.Endpoint == "" || !validPeerRef(peer.Endpoint, peer.Ref) {
|
|
return Peer{}, errors.New("peer messaging: invalid registry record")
|
|
}
|
|
peer.Identity = normalizeIdentity(peer.Identity)
|
|
return peer, nil
|
|
}
|
|
|
|
func (service *Service) removeOwnRecord(endpoint string) error {
|
|
path := service.recordPath()
|
|
peer, err := readPeerRecord(path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("peer messaging: verify registry record before removal: %w", err)
|
|
}
|
|
if peer.Endpoint != endpoint {
|
|
return nil
|
|
}
|
|
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return fmt.Errorf("peer messaging: remove registry record: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func resolvePeer(peers []Peer, target string) (Peer, error) {
|
|
target = strings.TrimSpace(target)
|
|
if target == "" {
|
|
return Peer{}, errors.New("peer messaging: recipient must not be empty")
|
|
}
|
|
for _, peer := range peers {
|
|
if peer.SessionID == target {
|
|
return peer, nil
|
|
}
|
|
}
|
|
if name, ref, ok := splitPeerTarget(target); ok {
|
|
for _, peer := range peers {
|
|
peerName := strings.TrimSpace(peer.Name)
|
|
if peerName == "" {
|
|
peerName = "Zero session"
|
|
}
|
|
if strings.EqualFold(peer.Ref, ref) && strings.EqualFold(peerName, name) {
|
|
return peer, nil
|
|
}
|
|
}
|
|
return Peer{}, fmt.Errorf("peer messaging: no reachable session named %q", target)
|
|
}
|
|
matches := make([]Peer, 0, 2)
|
|
for _, peer := range peers {
|
|
if strings.EqualFold(peer.Name, target) {
|
|
matches = append(matches, peer)
|
|
}
|
|
}
|
|
if len(matches) == 1 {
|
|
return matches[0], nil
|
|
}
|
|
if len(matches) > 1 {
|
|
return Peer{}, fmt.Errorf("peer messaging: %q is ambiguous; use name [ref] from list_sessions", target)
|
|
}
|
|
return Peer{}, fmt.Errorf("peer messaging: no reachable session named %q", target)
|
|
}
|
|
|
|
func splitPeerTarget(target string) (name, ref string, ok bool) {
|
|
open := strings.LastIndex(target, " [")
|
|
if open <= 0 || !strings.HasSuffix(target, "]") {
|
|
return "", "", false
|
|
}
|
|
name = strings.TrimSpace(target[:open])
|
|
ref = target[open+2 : len(target)-1]
|
|
if name == "" || len(ref) < 8 || len(ref) > peerRefHexLength || len(ref)%2 != 0 {
|
|
return "", "", false
|
|
}
|
|
if _, err := hex.DecodeString(ref); err != nil {
|
|
return "", "", false
|
|
}
|
|
return name, ref, true
|
|
}
|
|
|
|
func displayPeer(peer Peer) string {
|
|
name := strings.TrimSpace(peer.Name)
|
|
if name == "" {
|
|
name = "Zero session"
|
|
}
|
|
return fmt.Sprintf("%s [%s]", name, peer.Ref)
|
|
}
|
|
|
|
func normalizeIdentity(identity Identity) Identity {
|
|
identity.SessionID = truncateRunes(strings.TrimSpace(sanitizePlainText(identity.SessionID, false)), 256)
|
|
identity.Name = truncateRunes(strings.TrimSpace(sanitizePlainText(identity.Name, false)), 80)
|
|
if strings.ContainsAny(identity.Name, "[]") {
|
|
identity.Name = ""
|
|
}
|
|
identity.Cwd = truncateRunes(strings.TrimSpace(sanitizePlainText(identity.Cwd, false)), 4096)
|
|
switch identity.PermissionClass {
|
|
case PermissionPrompting, PermissionBypass:
|
|
default:
|
|
identity.PermissionClass = ""
|
|
}
|
|
return identity
|
|
}
|
|
|
|
func truncateRunes(value string, limit int) string {
|
|
runes := []rune(value)
|
|
if len(runes) <= limit {
|
|
return value
|
|
}
|
|
return string(runes[:limit])
|
|
}
|
|
|
|
func normalizeSummary(summary string) string {
|
|
summary = strings.Split(summary, "\n")[0]
|
|
summary = strings.TrimSpace(sanitizePlainText(summary, false))
|
|
runes := []rune(summary)
|
|
if len(runes) > 200 {
|
|
summary = string(runes[:200])
|
|
}
|
|
return summary
|
|
}
|
|
|
|
func normalizeBody(body string) string {
|
|
return strings.TrimSpace(sanitizePlainText(body, true))
|
|
}
|
|
|
|
func normalizeRemoteError(message string) string {
|
|
message = truncateRunes(strings.TrimSpace(sanitizePlainText(message, false)), peerErrorMaxRunes)
|
|
if message == "" {
|
|
return "receiver rejected the message"
|
|
}
|
|
return message
|
|
}
|
|
|
|
func sanitizePlainText(value string, multiline bool) string {
|
|
return strings.Map(func(char rune) rune {
|
|
if multiline && (char == '\n' || char == '\t') {
|
|
return char
|
|
}
|
|
if unicode.IsControl(char) {
|
|
return -1
|
|
}
|
|
return char
|
|
}, value)
|
|
}
|
|
|
|
func peerRef(endpoint string) string {
|
|
sum := sha256.Sum256([]byte(endpoint))
|
|
return hex.EncodeToString(sum[:peerRefHexLength/2])
|
|
}
|
|
|
|
func validPeerRef(endpoint, ref string) bool {
|
|
if ref == peerRef(endpoint) {
|
|
return true
|
|
}
|
|
// Accept references from a process that was already running during an
|
|
// in-place upgrade. New sessions always publish the wider reference.
|
|
sum := sha256.Sum256([]byte(endpoint))
|
|
return ref == hex.EncodeToString(sum[:4])
|
|
}
|
|
|
|
func randomHex(bytes int) (string, error) {
|
|
value := make([]byte, bytes)
|
|
if _, err := rand.Read(value); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(value), nil
|
|
}
|