mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -10:00
* Add cross-session messaging * Harden cross-session messaging Authenticate sender metadata against live registry records, bound peer lifecycle state, preserve records across transient discovery failures, and make receipt delivery direct and bounded. Also serialize TUI peer approvals, harden platform runtime paths and transport deadlines, widen session references, and add regression coverage for identity spoofing, queue limits, path boundaries, and tool failures. * Support canonical macOS socket paths Resolve Unix transport directories to their physical path before applying strict no-symlink traversal, allowing the standard /var to /private/var macOS alias without weakening runtime-directory validation. * Fix macOS socket path regression test Create the canonicalization fixture beneath a guaranteed short Unix path so it exercises the primary endpoint on macOS, and report temporary-directory cleanup failures. * Harden peer messaging lifecycle * Fix Windows peer directory traversal * Handle Windows token-owned peer directories * Accept equivalent Windows ACL masks * Build Windows peer ACL explicitly * Pin Windows ACL trustee SIDs * Validate Windows peer ACL semantics * Propagate Windows peer handle cleanup errors * Use null handle for Windows close failure test * Test held-message eviction callback
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/Gitlawb/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
|
|
}
|