Skip to content

Commit 8125129

Browse files
committed
feat(api): operator endpoints for the active bandwidth probe
Three operator-secret-gated routes the active bandwidth probe needs: GET /network/provider-bandwidth-test?bytes=N POST /network/provider-bandwidth-result POST /network/provider-bandwidth-reserve The download endpoint is the target the prober pulls through a provider's tunnel to measure its throughput. It streams min(N, 5MB) from a small repeating block through an io.LimitReader, so the byte count is never allocated, and the clamp keeps a single request from being an open-ended resource commitment. A `bytes` that is absent, malformed, or non-positive falls back to 1MiB: `bytes=0` parses cleanly, and an empty body would hand the prober a zero-byte sample to divide by. The result endpoint stores the measurement with Source "active" and window_start == window_end (an active probe is a point measurement, not an aggregate over a window). A non-positive rate or sample size is rejected before anything is written -- a zero measurement is not usable and must never overwrite a real figure. The reserve endpoint wraps ReserveProviderBandwidthSlot. That function is built for a caller that can set a RunAt, so when the current hour is full it succeeds with a reservation in a later bucket; the prober measures over a tunnel it already has open, right now, so later-hour budget is useless to it and a 200 would let it spend the whole daily ceiling inside one hour. A deferred reservation is therefore cancelled again and the request answered 429 with Retry-After, which the prober treats as "skip this provider". All three reuse the existing operator auth unchanged: same X-UR-Operator-Secret header, same constant-time hmac.Equal compare, same fail-closed memoized vault read as the egress location ingest.
1 parent aacdbb6 commit 8125129

3 files changed

Lines changed: 840 additions & 0 deletions

File tree

api/api.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ func Routes() []*router.Route {
5454
router.NewRoute("POST", "/network/provider-egress-location", handlers.ProviderEgressLocationSubmit),
5555
router.NewRoute("GET", "/network/provider-egress-due", handlers.ProviderEgressLocationDue),
5656
router.NewRoute("POST", "/network/provider-egress-attempt", handlers.ProviderEgressLocationAttempt),
57+
// operator-to-server, gated by the same operator secret as the egress
58+
// location ingest above: the active bandwidth probe's download target,
59+
// its result submission, and the byte-budget reservation the prober
60+
// takes before spending any probe bytes
61+
router.NewRoute("GET", "/network/provider-bandwidth-test", handlers.ProviderBandwidthTest),
62+
router.NewRoute("POST", "/network/provider-bandwidth-result", handlers.ProviderBandwidthResult),
63+
router.NewRoute("POST", "/network/provider-bandwidth-reserve", handlers.ProviderBandwidthReserve),
5764
router.NewRoute("GET", "/network/clients", handlers.NetworkClients),
5865
router.NewRoute("GET", "/network/peers", handlers.NetworkPeers),
5966
router.NewRoute("GET", "/network/provider-locations", handlers.NetworkGetProviderLocations),
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
package handlers
2+
3+
import (
4+
"crypto/hmac"
5+
"encoding/json"
6+
"io"
7+
"net/http"
8+
"strconv"
9+
"time"
10+
11+
"github.com/urnetwork/glog"
12+
13+
"github.com/urnetwork/server"
14+
"github.com/urnetwork/server/model"
15+
)
16+
17+
// maxProviderBandwidthTestBytes bounds a single download. Without a clamp the
18+
// endpoint is an open-ended resource commitment per request: a caller could
19+
// ask for gigabytes and hold a connection (and the egress bytes that go with
20+
// it) for as long as it liked. 5 MB matches the spec's per-probe sizing and
21+
// the per-probe figure the byte budget reserves against
22+
// (model.MaxProviderBandwidthBytesPerProbe).
23+
const maxProviderBandwidthTestBytes = 5 * 1024 * 1024
24+
25+
// defaultProviderBandwidthTestBytes is used when `bytes` is absent, malformed,
26+
// or non-positive. `bytes=0` and `bytes=-1` parse cleanly, so they are not
27+
// caught by a malformed-input check -- and streaming an empty body for them
28+
// would hand the prober a zero-byte sample to divide by.
29+
const defaultProviderBandwidthTestBytes = 1024 * 1024
30+
31+
// maxProviderBandwidthBody bounds the request body of the two POST endpoints.
32+
// Both carry a fixed handful of scalars.
33+
const maxProviderBandwidthBody = 4 * 1024
34+
35+
// providerBandwidthTestBlock is the unit the download endpoint repeats. The
36+
// content is irrelevant -- only the byte count is measured -- so this is one
37+
// small shared block streamed over and over rather than a per-request
38+
// allocation of the full byte count.
39+
var providerBandwidthTestBlock = make([]byte, 32*1024)
40+
41+
// repeatingReader yields providerBandwidthTestBlock endlessly. Bounded by an
42+
// io.LimitReader at the call site, so it never needs an end of its own.
43+
type repeatingReader struct {
44+
block []byte
45+
offset int
46+
}
47+
48+
func (self *repeatingReader) Read(p []byte) (int, error) {
49+
n := copy(p, self.block[self.offset:])
50+
self.offset = (self.offset + n) % len(self.block)
51+
return n, nil
52+
}
53+
54+
// authorizeOperator applies the same operator-secret check the provider egress
55+
// location ingest endpoint uses: the shared secret from the vault
56+
// (operatorIngestSecret, memoized and fail-closed) compared in constant time
57+
// against the X-UR-Operator-Secret header. These are operator-to-server
58+
// routes, not client routes -- there is no network jwt involved.
59+
//
60+
// An unconfigured vault leaves the secret empty, which rejects every request
61+
// rather than accepting every request.
62+
func authorizeOperator(r *http.Request) bool {
63+
secret := operatorIngestSecret()
64+
provided := r.Header.Get(operatorSecretHeader)
65+
return secret != "" && provided != "" && hmac.Equal([]byte(secret), []byte(provided))
66+
}
67+
68+
// readOperatorRequestBody reads a bounded operator request body, writing the
69+
// error response itself and reporting whether the caller should continue.
70+
func readOperatorRequestBody(w http.ResponseWriter, r *http.Request, out any) bool {
71+
body, err := io.ReadAll(io.LimitReader(r.Body, maxProviderBandwidthBody+1))
72+
if err != nil {
73+
http.Error(w, "Bad request", http.StatusBadRequest)
74+
return false
75+
}
76+
if maxProviderBandwidthBody < len(body) {
77+
http.Error(w, "Request too large", http.StatusRequestEntityTooLarge)
78+
return false
79+
}
80+
if err := json.Unmarshal(body, out); err != nil {
81+
http.Error(w, "Bad request", http.StatusBadRequest)
82+
return false
83+
}
84+
return true
85+
}
86+
87+
// ProviderBandwidthTest streams a bounded number of bytes. The active
88+
// bandwidth probe needs something to download *through* a provider's tunnel to
89+
// measure that tunnel's throughput; this is that target. It is
90+
// operator-to-server, gated by the operator secret, so ordinary clients cannot
91+
// use the deployment as a free speed-test target.
92+
//
93+
// The content is arbitrary -- only the byte count matters -- and it is streamed
94+
// from a small repeating block through an io.LimitReader, never materialized
95+
// in full.
96+
func ProviderBandwidthTest(w http.ResponseWriter, r *http.Request) {
97+
if !authorizeOperator(r) {
98+
http.Error(w, "Unauthorized", http.StatusUnauthorized)
99+
return
100+
}
101+
102+
byteCount := int64(defaultProviderBandwidthTestBytes)
103+
if requested, err := strconv.ParseInt(r.URL.Query().Get("bytes"), 10, 64); err == nil && 0 < requested {
104+
byteCount = requested
105+
}
106+
if maxProviderBandwidthTestBytes < byteCount {
107+
byteCount = maxProviderBandwidthTestBytes
108+
}
109+
110+
w.Header().Set("Content-Type", "application/octet-stream")
111+
w.Header().Set("Content-Length", strconv.FormatInt(byteCount, 10))
112+
w.Header().Set("Cache-Control", "no-store")
113+
w.WriteHeader(http.StatusOK)
114+
115+
source := &repeatingReader{block: providerBandwidthTestBlock}
116+
if _, err := io.Copy(w, io.LimitReader(source, byteCount)); err != nil {
117+
// the prober hanging up mid-download is ordinary (it stops at its own
118+
// time or byte cap), so this is not an error worth escalating
119+
glog.Infof("[pbw]bandwidth test stream ended early. err = %s\n", err)
120+
}
121+
}
122+
123+
// SubmitProviderBandwidthArgs is an active bandwidth measurement taken by the
124+
// prober over a provider's tunnel.
125+
type SubmitProviderBandwidthArgs struct {
126+
ClientId server.Id `json:"client_id"`
127+
BytesPerSecond float64 `json:"bytes_per_second"`
128+
SampleByteCount int64 `json:"sample_byte_count"`
129+
}
130+
131+
// ProviderBandwidthResult stores an active bandwidth measurement. An active
132+
// probe is a point measurement rather than an aggregate over a window, so
133+
// window_start and window_end are both the arrival time.
134+
//
135+
// A non-positive rate or sample size is not a usable measurement, and storing
136+
// one would overwrite a real figure with a meaningless one -- so those are
137+
// rejected before anything is written.
138+
func ProviderBandwidthResult(w http.ResponseWriter, r *http.Request) {
139+
if !authorizeOperator(r) {
140+
http.Error(w, "Unauthorized", http.StatusUnauthorized)
141+
return
142+
}
143+
144+
var args SubmitProviderBandwidthArgs
145+
if !readOperatorRequestBody(w, r, &args) {
146+
return
147+
}
148+
149+
if args.ClientId == (server.Id{}) {
150+
http.Error(w, "Missing client id.", http.StatusBadRequest)
151+
return
152+
}
153+
if args.BytesPerSecond <= 0 {
154+
http.Error(w, "bytes_per_second must be positive.", http.StatusBadRequest)
155+
return
156+
}
157+
if args.SampleByteCount <= 0 {
158+
http.Error(w, "sample_byte_count must be positive.", http.StatusBadRequest)
159+
return
160+
}
161+
162+
now := server.NowUtc()
163+
model.StoreProviderBandwidth(r.Context(), &model.ProviderBandwidth{
164+
ClientId: args.ClientId,
165+
BytesPerSecond: args.BytesPerSecond,
166+
Source: model.ProviderBandwidthSourceActive,
167+
SampleByteCount: args.SampleByteCount,
168+
WindowStart: now,
169+
WindowEnd: now,
170+
})
171+
172+
w.Header().Set("Content-Type", "application/json")
173+
if err := json.NewEncoder(w).Encode(map[string]any{}); err != nil {
174+
glog.Infof("[pbw]could not write response. err = %s\n", err)
175+
}
176+
}
177+
178+
// ReserveProviderBandwidthArgs asks for budget to run one active probe.
179+
type ReserveProviderBandwidthArgs struct {
180+
ClientId server.Id `json:"client_id"`
181+
ByteCount int64 `json:"byte_count"`
182+
}
183+
184+
// ReserveProviderBandwidthResult carries the reservation the prober just took.
185+
// BucketStart is always the current hourly bucket (see ProviderBandwidthReserve).
186+
type ReserveProviderBandwidthResult struct {
187+
ReservationId server.Id `json:"reservation_id"`
188+
BucketStart time.Time `json:"bucket_start"`
189+
}
190+
191+
// ProviderBandwidthReserve reserves deployment-wide byte budget for one active
192+
// bandwidth probe. Active probing pulls real data through a provider's tunnel,
193+
// which is real paid contract traffic, so it is rationed
194+
// (model.ReserveProviderBandwidthSlot).
195+
//
196+
// The prober measures over a tunnel it already has open, right now: it has no
197+
// use for budget in a later hour. model.ReserveProviderBandwidthSlot will
198+
// happily defer a reservation into a future bucket for callers that can
199+
// schedule a RunAt, so when it does that here the reservation is cancelled
200+
// again and the request answered 429 -- the hourly ceiling would otherwise be
201+
// decorative, since the prober could spend the whole daily budget inside one
202+
// hour. Retry-After points at the bucket that does have room. (The plan
203+
// specifies 429 "when every lookahead bucket is full"; this returns 429 on a
204+
// strict superset of that, for the same "skip this provider cleanly" reason.)
205+
func ProviderBandwidthReserve(w http.ResponseWriter, r *http.Request) {
206+
if !authorizeOperator(r) {
207+
http.Error(w, "Unauthorized", http.StatusUnauthorized)
208+
return
209+
}
210+
211+
var args ReserveProviderBandwidthArgs
212+
if !readOperatorRequestBody(w, r, &args) {
213+
return
214+
}
215+
216+
if args.ClientId == (server.Id{}) {
217+
http.Error(w, "Missing client id.", http.StatusBadRequest)
218+
return
219+
}
220+
if args.ByteCount <= 0 {
221+
http.Error(w, "byte_count must be positive.", http.StatusBadRequest)
222+
return
223+
}
224+
// the byte count is caller-supplied; a probe never legitimately needs more
225+
// than the per-probe figure, and an oversized request must not be able to
226+
// swallow a large slice of a bucket in one reservation
227+
byteCount := args.ByteCount
228+
if model.MaxProviderBandwidthBytesPerProbe < byteCount {
229+
byteCount = model.MaxProviderBandwidthBytesPerProbe
230+
}
231+
232+
ctx := r.Context()
233+
now := server.NowUtc()
234+
currentBucketStart := now.UTC().Truncate(model.ProviderBandwidthBucketDuration)
235+
236+
reservationId, bucketStart, err := model.ReserveProviderBandwidthSlot(ctx, args.ClientId, byteCount)
237+
if err != nil {
238+
// every bucket in the lookahead window is full: the deployment's daily
239+
// budget is exhausted
240+
writeProviderBandwidthBudgetExhausted(w, currentBucketStart.Add(model.ProviderBandwidthBucketDuration).Sub(now), err.Error())
241+
return
242+
}
243+
if bucketStart.After(currentBucketStart) {
244+
// budget exists, but not until a later hour -- of no use to a probe
245+
// that runs now, so give it back rather than burning it on a request
246+
// that is about to be skipped
247+
model.CancelProviderBandwidthReservation(ctx, reservationId)
248+
writeProviderBandwidthBudgetExhausted(w, bucketStart.Sub(now), "The active bandwidth probe budget for this hour has been reached.")
249+
return
250+
}
251+
252+
w.Header().Set("Content-Type", "application/json")
253+
result := &ReserveProviderBandwidthResult{
254+
ReservationId: reservationId,
255+
BucketStart: bucketStart,
256+
}
257+
if err := json.NewEncoder(w).Encode(result); err != nil {
258+
glog.Infof("[pbw]could not write response. err = %s\n", err)
259+
}
260+
}
261+
262+
func writeProviderBandwidthBudgetExhausted(w http.ResponseWriter, retryAfter time.Duration, message string) {
263+
retryAfterSeconds := int64(retryAfter.Seconds())
264+
if retryAfterSeconds < 1 {
265+
retryAfterSeconds = 1
266+
}
267+
w.Header().Set("Retry-After", strconv.FormatInt(retryAfterSeconds, 10))
268+
http.Error(w, message, http.StatusTooManyRequests)
269+
}

0 commit comments

Comments
 (0)