// 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 { ok, _ := g.TryAllow(n) return ok } // Refusal reasons from TryAllow. The distinction is not cosmetic: "too fast just now" is // transient and a caller should pace and carry on, while "budget" and "expired" are terminal and // a caller that keeps trying is only wasting its own run. const ( RefusalNone = "" RefusalBudget = "budget" RefusalExpired = "expired" RefusalRate = "rate" ) // TryAllow reports whether n more bytes may be sent now, consuming the budget when they may, and // says why not when they may not. // // The rate limit is a token bucket: allowance = burst + rate x elapsed. An earlier version // exempted the first 50 ms from the check entirely, meaning to be lenient at startup. The effect // was the opposite - a sender could dump an unbounded burst into that window, and the moment the // check switched on it compared those bytes against 50 ms worth of allowance and refused // everything until real time caught up. A sustained send died about fifty milliseconds in, having // looked perfectly fine in every short test. A bucket has no such cliff: it is smooth from t=0. func (g *Grant) TryAllow(n int) (bool, string) { g.mu.Lock() defer g.mu.Unlock() if time.Now().After(g.ExpiresAt) { return false, RefusalExpired } if g.sentBytes+int64(n) > g.MaxBytes { return false, RefusalBudget } // kbps -> bytes/s is kbps*1000/8 = kbps*125. bytesPerSec := float64(g.MaxKbps) * 125 elapsed := time.Since(g.started).Seconds() allowed := burstBytes(bytesPerSec) + bytesPerSec*elapsed if float64(g.sentBytes+int64(n)) > allowed { return false, RefusalRate } g.sentBytes += int64(n) return true, RefusalNone } // burstBytes is the bucket's depth: 100 ms of the allowed rate, floored at a single ordinary // datagram. // // The floor exists only so that one packet is never refused outright by a very slow grant — it is // deliberately one datagram and not more. A generous floor would undo the rate ceiling at low // rates: at 8 kbps a 64 KB burst is sixty-four seconds' worth, which is exactly the instant dump // the ceiling is there to prevent. One datagram is 1.5 seconds' worth at that rate and nothing at // any realistic one. func burstBytes(bytesPerSec float64) float64 { const oneDatagram = 1500 b := bytesPerSec * 0.1 if b < oneDatagram { b = oneDatagram } return b } // Sent returns how many bytes this grant has consumed. func (g *Grant) Sent() int64 { g.mu.Lock() defer g.mu.Unlock() return g.sentBytes }