// SPDX-FileCopyrightText: 2026 Echolot contributors // SPDX-License-Identifier: GPL-3.0-or-later package session import ( "sync" "time" ) // Grant is the spec §3.4 "asymmetric grant": the ONLY thing that lets the server send more than // it receives. Without one, every response is capped at the request size, which is what keeps an // unauthenticated observer from using this server as a reflector/amplifier. // // A grant is created by an authenticated control-plane action (§5) for one session, and is bounded // three ways: total bytes, send rate, and wall-clock expiry. It is consumed as the data plane // sends; when the budget runs out the send stops, so a bug in an action cannot turn into an // unbounded flood. type Grant struct { ActionID string // Destination is fixed at creation to the session's observed data-plane source — a grant can // never be pointed somewhere else, so it cannot be used to attack a third party. Dest string MaxBytes int64 MaxKbps int ExpiresAt time.Time mu sync.Mutex sentBytes int64 started time.Time } // GrantLimits are the server-side ceilings an action may not exceed, independent of what the // client asks for. type GrantLimits struct { MaxBytes int64 MaxKbps int MaxHold time.Duration } var DefaultGrantLimits = GrantLimits{ MaxBytes: 8 << 20, // 8 MiB per action MaxKbps: 50_000, // matches the profile's advertised max_kbps MaxHold: 30 * time.Second, // an action must finish inside this window } // NewGrant clamps the request to the server's limits and binds it to the session's observed // data-plane source. Returns nil when the session has no observed source yet — refusing to send // anywhere we have not verifiably received from is the whole point. func (s *Session) NewGrant(actionID string, wantBytes int64, wantKbps int, lim GrantLimits) *Grant { dest := s.DataSource() if !dest.IsValid() { return nil } if wantBytes <= 0 || wantBytes > lim.MaxBytes { wantBytes = lim.MaxBytes } if wantKbps <= 0 || wantKbps > lim.MaxKbps { wantKbps = lim.MaxKbps } g := &Grant{ ActionID: actionID, Dest: dest.String(), MaxBytes: wantBytes, MaxKbps: wantKbps, ExpiresAt: time.Now().Add(lim.MaxHold), started: time.Now(), } s.mu.Lock() s.grants = append(s.grants, g) s.mu.Unlock() return g } // Allow reports whether n more bytes may be sent now, consuming the budget when they may. It // enforces the byte ceiling, the expiry, and the average rate (by refusing early sends rather // than sleeping, so callers stay in control of pacing). func (g *Grant) Allow(n int) bool { g.mu.Lock() defer g.mu.Unlock() if time.Now().After(g.ExpiresAt) { return false } if g.sentBytes+int64(n) > g.MaxBytes { return false } // Average-rate check: bytes allowed so far = kbps/8 * elapsed_seconds. elapsed := time.Since(g.started).Seconds() allowed := float64(g.MaxKbps) * 125 * elapsed // kbps -> bytes/s is kbps*1000/8 = kbps*125 if elapsed > 0.05 && float64(g.sentBytes+int64(n)) > allowed { return false } g.sentBytes += int64(n) return true } // Sent returns how many bytes this grant has consumed. func (g *Grant) Sent() int64 { g.mu.Lock() defer g.mu.Unlock() return g.sentBytes }