• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

m-lab / locate / 21485597235

29 Jan 2026 04:08PM UTC coverage: 82.191% (+0.04%) from 82.148%
21485597235

Pull #248

github

bassosimone
fix(handler): handle preflight CORS request

When we make a request with `Authorization` for m-lab/locate from
https://github.com/m-lab/mlab-speedtest/pull/81, the request itself stops
being a [simple request](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS#simple_requests)
and becomes a [preflighted request](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS#preflighted_requests).

This happens because the `Authorization` header is not in the set
of headers allowed for simple requests. As such, a browser will issue
a `OPTIONS *` request first to check whether the server would accept
such a request or not.

In turn, this causes a `403` when trying to invoke m-lab/locate in
https://github.com/m-lab/mlab-speedtest/pull/81.

To address this issue, we extend the set of allowed methods to also
include `OPTIONS` and we handle `OPTIONS *` by returning `204`.
Pull Request #248: fix(handler): handle preflight CORS request

8 of 10 new or added lines in 1 file covered. (80.0%)

13 existing lines in 1 file now uncovered.

2386 of 2903 relevant lines covered (82.19%)

6.05 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

88.42
/handler/handler.go
1
// Package handler provides a client and handlers for responding to locate
2
// requests.
3
package handler
4

5
import (
6
        "bytes"
7
        "context"
8
        "encoding/json"
9
        "errors"
10
        "fmt"
11
        "html/template"
12
        "math/rand"
13
        "net"
14
        "net/http"
15
        "net/url"
16
        "path"
17
        "strconv"
18
        "strings"
19
        "time"
20

21
        "github.com/go-jose/go-jose/v4/jwt"
22
        "github.com/google/uuid"
23
        log "github.com/sirupsen/logrus"
24

25
        "github.com/m-lab/go/rtx"
26
        v2 "github.com/m-lab/locate/api/v2"
27
        "github.com/m-lab/locate/clientgeo"
28
        "github.com/m-lab/locate/heartbeat"
29
        "github.com/m-lab/locate/limits"
30
        "github.com/m-lab/locate/metrics"
31
        "github.com/m-lab/locate/siteinfo"
32
        "github.com/m-lab/locate/static"
33
        prom "github.com/prometheus/client_golang/api/prometheus/v1"
34
        "github.com/prometheus/common/model"
35
)
36

37
var (
38
        errFailedToLookupClient = errors.New("Failed to look up client location")
39
        tooManyRequests         = "Too many periodic requests. Please contact support@measurementlab.net."
40
)
41

42
// Signer defines how access tokens are signed.
43
type Signer interface {
44
        Sign(cl jwt.Claims) (string, error)
45
}
46

47
type Limiter interface {
48
        IsLimited(ip, ua string) (limits.LimitStatus, error)
49
}
50

51
// TierLimiter extends Limiter with tier-based limits for priority endpoints.
52
type TierLimiter interface {
53
        Limiter
54
        IsLimitedWithTier(org, ip string, tierConfig limits.LimitConfig) (limits.LimitStatus, error)
55
}
56

57
// Client handles HTTP requests for the locate service, including nearest server
58
// lookups, heartbeat processing, and monitoring endpoints. It maintains dependencies
59
// for geolocation, rate limiting, access token signing, and metrics collection.
60
// TODO: This should probably be a Handler, not a Client.
61
type Client struct {
62
        Signer
63
        project string
64
        LocatorV2
65
        ClientLocator
66
        PrometheusClient
67
        targetTmpl       *template.Template
68
        agentLimits      limits.Agents
69
        tierLimits       limits.TierLimits
70
        ipLimiter        TierLimiter
71
        earlyExitClients map[string]bool
72
        jwtVerifier      Verifier
73
}
74

75
// LocatorV2 defines how the Nearest handler requests machines nearest to the
76
// client.
77
type LocatorV2 interface {
78
        Nearest(service string, lat, lon float64, opts *heartbeat.NearestOptions) (*heartbeat.TargetInfo, error)
79
        heartbeat.StatusTracker
80
}
81

82
// ClientLocator defines the interface for looking up the client geolocation.
83
type ClientLocator interface {
84
        Locate(req *http.Request) (*clientgeo.Location, error)
85
}
86

87
// PrometheusClient defines the interface to query Prometheus.
88
type PrometheusClient interface {
89
        Query(ctx context.Context, query string, ts time.Time, opts ...prom.Option) (model.Value, prom.Warnings, error)
90
}
91

92
type paramOpts struct {
93
        raw       url.Values
94
        version   string
95
        ranks     map[string]int
96
        svcParams map[string]float64
97
}
98

99
func init() {
1✔
100
        log.SetFormatter(&log.JSONFormatter{})
1✔
101
        log.SetLevel(log.InfoLevel)
1✔
102
}
1✔
103

104
// NewClient creates a new client.
105
func NewClient(project string, private Signer, locatorV2 LocatorV2, client ClientLocator,
106
        promClient PrometheusClient, lmts limits.Agents, tierLmts limits.TierLimits, limiter TierLimiter, earlyExitClients []string, jwtVerifier Verifier) *Client {
43✔
107
        // Convert slice to map for O(1) lookups
43✔
108
        earlyExitMap := make(map[string]bool)
43✔
109
        for _, client := range earlyExitClients {
43✔
110
                earlyExitMap[client] = true
×
111
        }
×
112
        return &Client{
43✔
113
                Signer:           private,
43✔
114
                project:          project,
43✔
115
                LocatorV2:        locatorV2,
43✔
116
                ClientLocator:    client,
43✔
117
                PrometheusClient: promClient,
43✔
118
                targetTmpl:       template.Must(template.New("name").Parse("{{.Hostname}}{{.Ports}}")),
43✔
119
                agentLimits:      lmts,
43✔
120
                tierLimits:       tierLmts,
43✔
121
                ipLimiter:        limiter,
43✔
122
                earlyExitClients: earlyExitMap,
43✔
123
                jwtVerifier:      jwtVerifier,
43✔
124
        }
43✔
125
}
126

127
func (c *Client) extraParams(hostname string, index int, p paramOpts) url.Values {
19✔
128
        v := url.Values{}
19✔
129

19✔
130
        // Add client parameters.
19✔
131
        for key := range p.raw {
34✔
132
                if strings.HasPrefix(key, "client_") {
24✔
133
                        // note: we only use the first value.
9✔
134
                        v.Set(key, p.raw.Get(key))
9✔
135
                }
9✔
136

137
                val, ok := p.svcParams[key]
15✔
138
                if ok && rand.Float64() < val {
20✔
139
                        v.Set(key, p.raw.Get(key))
5✔
140
                }
5✔
141
        }
142

143
        // Add early_exit parameter for specified clients
144
        clientName := p.raw.Get("client_name")
19✔
145
        if clientName != "" && c.earlyExitClients[clientName] {
20✔
146
                v.Set(static.EarlyExitParameter, static.EarlyExitDefaultValue)
1✔
147
        }
1✔
148

149
        // Add Locate Service version.
150
        v.Set("locate_version", p.version)
19✔
151

19✔
152
        // Add metro rank.
19✔
153
        rank, ok := p.ranks[hostname]
19✔
154
        if ok {
23✔
155
                v.Set("metro_rank", strconv.Itoa(rank))
4✔
156
        }
4✔
157

158
        // Add result index.
159
        v.Set("index", strconv.Itoa(index))
19✔
160

19✔
161
        return v
19✔
162
}
163

164
// Nearest uses an implementation of the LocatorV2 interface to look up
165
// nearest servers.
166
func (c *Client) Nearest(rw http.ResponseWriter, req *http.Request) {
14✔
167
        result := v2.NearestResult{}
14✔
168
        setHeaders(rw)
14✔
169

14✔
170
        if err := req.ParseForm(); err != nil {
14✔
171
                log.Debugf("ParseForm error: %v", err)
×
172
                result.Error = v2.NewError("server", "Failed to parse form", http.StatusInternalServerError)
×
173
                writeResult(rw, result.Error.Status, result)
×
174
                metrics.RequestsTotal.WithLabelValues("nearest", "server",
×
175
                        http.StatusText(result.Error.Status)).Inc()
×
176
                return
×
177
        }
×
178

179
        if c.limitRequest(time.Now().UTC(), req) {
15✔
180
                result.Error = v2.NewError("client", tooManyRequests, http.StatusTooManyRequests)
1✔
181
                writeResult(rw, result.Error.Status, &result)
1✔
182
                metrics.RequestsTotal.WithLabelValues("nearest", "request limit", http.StatusText(result.Error.Status)).Inc()
1✔
183
                return
1✔
184
        }
1✔
185

186
        // Check rate limit for IP and UA.
187
        if c.ipLimiter != nil {
18✔
188
                ip := getRemoteAddr(req)
5✔
189
                if ip != "" {
10✔
190
                        // An empty UA is technically possible.
5✔
191
                        ua := req.Header.Get("User-Agent")
5✔
192
                        status, err := c.ipLimiter.IsLimited(ip, ua)
5✔
193
                        if err != nil {
6✔
194
                                // Log error but don't block request (fail open).
1✔
195
                                // TODO: Add tests for this path.
1✔
196
                                log.Printf("Rate limiter error: %v", err)
1✔
197
                        } else if status.IsLimited {
7✔
198
                                // Log IP and UA and block the request.
2✔
199
                                result.Error = v2.NewError("client", tooManyRequests, http.StatusTooManyRequests)
2✔
200
                                metrics.RequestsTotal.WithLabelValues("nearest", "rate limit",
2✔
201
                                        http.StatusText(result.Error.Status)).Inc()
2✔
202
                                // If the client provided a client_name, we want to know how many times
2✔
203
                                // that client_name was rate limited. This may be empty, which is fine.
2✔
204
                                clientName := req.Form.Get("client_name")
2✔
205
                                metrics.RateLimitedTotal.WithLabelValues(clientName, status.LimitType).Inc()
2✔
206

2✔
207
                                log.Printf("Rate limit (%s) exceeded for IP: %s, client: %s, UA: %s", ip,
2✔
208
                                        status.LimitType, clientName, ua)
2✔
209
                                writeResult(rw, result.Error.Status, &result)
2✔
210
                                return
2✔
211
                        }
2✔
212
                } else {
×
213
                        // This should never happen if Locate is deployed on AppEngine.
×
214
                        log.Println("Cannot find IP address for rate limiting.")
×
215
                }
×
216
        }
217

218
        c.handleNearestRequest(rw, req, &result, "nearest")
11✔
219
}
220

221
// handleNearestRequest is a helper that contains the common logic for looking up
222
// nearest machines and generating the response. It's used by both Nearest and
223
// PriorityNearest handlers.
224
func (c *Client) handleNearestRequest(rw http.ResponseWriter, req *http.Request, result *v2.NearestResult, metricLabel string) {
15✔
225
        experiment, service := getExperimentAndService(req.URL.Path)
15✔
226

15✔
227
        // Look up client location.
15✔
228
        loc, err := c.checkClientLocation(rw, req)
15✔
229
        if err != nil {
17✔
230
                status := http.StatusServiceUnavailable
2✔
231
                result.Error = v2.NewError("nearest", "Failed to lookup nearest machines", status)
2✔
232
                writeResult(rw, result.Error.Status, result)
2✔
233
                metrics.RequestsTotal.WithLabelValues(metricLabel, "client location",
2✔
234
                        http.StatusText(result.Error.Status)).Inc()
2✔
235
                return
2✔
236
        }
2✔
237

238
        // Parse client location.
239
        lat, errLat := strconv.ParseFloat(loc.Latitude, 64)
13✔
240
        lon, errLon := strconv.ParseFloat(loc.Longitude, 64)
13✔
241
        if errLat != nil || errLon != nil {
14✔
242
                result.Error = v2.NewError("client", errFailedToLookupClient.Error(), http.StatusInternalServerError)
1✔
243
                writeResult(rw, result.Error.Status, result)
1✔
244
                metrics.RequestsTotal.WithLabelValues(metricLabel, "parse client location",
1✔
245
                        http.StatusText(result.Error.Status)).Inc()
1✔
246
                return
1✔
247
        }
1✔
248

249
        // Find the nearest targets using the client parameters.
250
        q := req.URL.Query()
12✔
251
        t := q.Get("machine-type")
12✔
252
        country := req.Header.Get("X-AppEngine-Country")
12✔
253
        sites := q["site"]
12✔
254
        org := q.Get("org")
12✔
255
        strict := false
12✔
256
        if qsStrict, err := strconv.ParseBool(q.Get("strict")); err == nil {
12✔
257
                strict = qsStrict
×
258
        }
×
259
        // If strict, override the country from the AppEngine header with the one in
260
        // the querystring.
261
        if strict {
12✔
262
                country = q.Get("country")
×
263
        }
×
264
        opts := &heartbeat.NearestOptions{Type: t, Country: country, Sites: sites, Org: org, Strict: strict}
12✔
265
        targetInfo, err := c.LocatorV2.Nearest(service, lat, lon, opts)
12✔
266
        if err != nil {
14✔
267
                result.Error = v2.NewError("nearest", "Failed to lookup nearest machines", http.StatusInternalServerError)
2✔
268
                writeResult(rw, result.Error.Status, result)
2✔
269
                metrics.RequestsTotal.WithLabelValues(metricLabel, "server location",
2✔
270
                        http.StatusText(result.Error.Status)).Inc()
2✔
271
                return
2✔
272
        }
2✔
273

274
        pOpts := paramOpts{
10✔
275
                raw:       req.Form,
10✔
276
                version:   "v2",
10✔
277
                ranks:     targetInfo.Ranks,
10✔
278
                svcParams: static.ServiceParams,
10✔
279
        }
10✔
280
        // Populate target URLs and write out response.
10✔
281
        c.populateURLs(targetInfo.Targets, targetInfo.URLs, experiment, pOpts)
10✔
282
        result.Results = targetInfo.Targets
10✔
283
        writeResult(rw, http.StatusOK, result)
10✔
284
        metrics.RequestsTotal.WithLabelValues(metricLabel, "success", http.StatusText(http.StatusOK)).Inc()
10✔
285
}
286

287
// PriorityNearest handles requests to /v2/priority/nearest with tier-based rate limiting.
288
// It requires a valid integration JWT with an int_id claim. Requests without valid
289
// credentials receive a 401 Unauthorized response.
290
func (c *Client) PriorityNearest(rw http.ResponseWriter, req *http.Request) {
8✔
291
        result := &v2.NearestResult{}
8✔
292
        setHeaders(rw)
8✔
293

8✔
294
        // Handle CORS preflight requests.
8✔
295
        if req.Method == http.MethodOptions {
9✔
296
                rw.WriteHeader(http.StatusNoContent)
1✔
297
                return
1✔
298
        }
1✔
299

300
        if err := req.ParseForm(); err != nil {
7✔
NEW
301
                log.Debugf("ParseForm error: %v", err)
×
NEW
302
                result.Error = v2.NewError("server", "Failed to parse form", http.StatusInternalServerError)
×
303
                writeResult(rw, result.Error.Status, result)
×
304
                metrics.RequestsTotal.WithLabelValues("priority_nearest", "server",
×
305
                        http.StatusText(result.Error.Status)).Inc()
×
306
                return
×
307
        }
×
308

309
        // Extract JWT claims from thee X-Endpoint-API-UserInfo header
310
        claims, err := c.extractJWTClaims(req)
7✔
311
        if err != nil {
8✔
312
                log.Debugf("Failed to extract JWT claims for priority endpoint: %v", err)
1✔
313
                result.Error = v2.NewError("auth", "Valid JWT token required", http.StatusUnauthorized)
1✔
314
                writeResult(rw, result.Error.Status, result)
1✔
315
                metrics.RequestsTotal.WithLabelValues("priority_nearest", "auth", http.StatusText(http.StatusUnauthorized)).Inc()
1✔
316
                return
1✔
317
        }
1✔
318

319
        // Extract integration ID claim
320
        intID, err := extractIntegrationID(claims)
6✔
321
        if err != nil {
7✔
322
                log.Debugf("Failed to extract int_id claim for priority endpoint: %v", err)
1✔
323
                result.Error = v2.NewError("auth", "Valid int_id claim required", http.StatusUnauthorized)
1✔
324
                writeResult(rw, result.Error.Status, result)
1✔
325
                metrics.RequestsTotal.WithLabelValues("priority_nearest", "auth", http.StatusText(http.StatusUnauthorized)).Inc()
1✔
326
                return
1✔
327
        }
1✔
328

329
        // Extract tier claim, defaulting to 0 if not present.
330
        // JSON numbers always unmarshal to float64 in Go.
331
        tier := 0
5✔
332
        if tierClaim, ok := claims["tier"]; ok {
9✔
333
                if v, ok := tierClaim.(float64); ok {
7✔
334
                        tier = int(v)
3✔
335
                } else {
4✔
336
                        log.Debugf("Unexpected tier claim type for int_id %s: %T, using default tier 0", intID, tierClaim)
1✔
337
                }
1✔
338
        }
339

340
        // Look up tier configuration
341
        tierConfig, ok := c.tierLimits[tier]
5✔
342
        if !ok {
6✔
343
                log.Debugf("Tier %d not configured for int_id %s, using tier 0", tier, intID)
1✔
344
                tier = 0
1✔
345
                tierConfig, ok = c.tierLimits[0]
1✔
346
                if !ok {
1✔
UNCOV
347
                        // Tier 0 not configured - this is a server misconfiguration
×
UNCOV
348
                        log.Errorf("Tier 0 not configured, cannot serve priority requests")
×
UNCOV
349
                        result.Error = v2.NewError("server", "Service misconfigured", http.StatusInternalServerError)
×
350
                        writeResult(rw, result.Error.Status, result)
×
351
                        metrics.RequestsTotal.WithLabelValues("priority_nearest", "config", http.StatusText(http.StatusInternalServerError)).Inc()
×
352
                        return
×
353
                }
×
354
        }
355

356
        // Apply tier-based rate limiting
357
        if c.ipLimiter != nil {
10✔
358
                ip := getRemoteAddr(req)
5✔
359
                if ip != "" {
10✔
360
                        status, err := c.ipLimiter.IsLimitedWithTier(intID, ip, tierConfig)
5✔
361
                        if err != nil {
5✔
UNCOV
362
                                // Log error but don't block request (fail open).
×
UNCOV
363
                                log.Errorf("Tier rate limiter error for int_id %s, tier %d: %v", intID, tier, err)
×
364
                        } else if status.IsLimited {
6✔
365
                                // Rate limited - block the request
1✔
366
                                result.Error = v2.NewError("client", tooManyRequests, http.StatusTooManyRequests)
1✔
367
                                metrics.RequestsTotal.WithLabelValues("priority_nearest", "rate limit",
1✔
368
                                        http.StatusText(result.Error.Status)).Inc()
1✔
369

1✔
370
                                clientName := req.Form.Get("client_name")
1✔
371
                                metrics.RateLimitedTotal.WithLabelValues(clientName, status.LimitType).Inc()
1✔
372

1✔
373
                                log.Debugf("Tier rate limit (%s) exceeded for int_id: %s, tier: %d, IP: %s, client: %s",
1✔
374
                                        status.LimitType, intID, tier, ip, clientName)
1✔
375
                                writeResult(rw, result.Error.Status, result)
1✔
376
                                return
1✔
377
                        }
1✔
UNCOV
378
                } else {
×
UNCOV
379
                        // This should never happen if Locate is deployed on AppEngine.
×
UNCOV
380
                        // For local deployments, RemoteAddr is assumed to always be there.
×
381
                        log.Warnf("Cannot find IP address for tier-based rate limiting.")
×
382
                }
×
383
        }
384

385
        // Rate limit passed - handle the nearest request
386
        c.handleNearestRequest(rw, req, result, "priority_nearest")
4✔
387
}
388

389
// extractIntegrationID extracts the "int_id" claim from integration JWT claims.
390
func extractIntegrationID(claims map[string]interface{}) (string, error) {
6✔
391
        intIDClaim, ok := claims["int_id"]
6✔
392
        if !ok {
7✔
393
                return "", fmt.Errorf("int_id claim not found in JWT")
1✔
394
        }
1✔
395

396
        intID, ok := intIDClaim.(string)
5✔
397
        if !ok {
5✔
UNCOV
398
                return "", fmt.Errorf("int_id claim is not a string")
×
UNCOV
399
        }
×
400

401
        if intID == "" {
5✔
402
                return "", fmt.Errorf("int_id claim is empty")
×
UNCOV
403
        }
×
404

405
        return intID, nil
5✔
406
}
407

408
// Live is a minimal handler to indicate that the server is operating at all.
409
func (c *Client) Live(rw http.ResponseWriter, req *http.Request) {
2✔
410
        fmt.Fprintf(rw, "ok")
2✔
411
}
2✔
412

413
// Ready reports whether the server is working as expected and ready to serve requests.
414
func (c *Client) Ready(rw http.ResponseWriter, req *http.Request) {
2✔
415
        if c.LocatorV2.Ready() {
3✔
416
                fmt.Fprintf(rw, "ok")
1✔
417
        } else {
2✔
418
                rw.WriteHeader(http.StatusInternalServerError)
1✔
419
                fmt.Fprintf(rw, "not ready")
1✔
420
        }
1✔
421
}
422

423
// Registrations returns information about registered machines. There are 3
424
// supported query parameters:
425
//
426
// * format - defines the format of the returned JSON
427
// * org - limits results to only records for the given organization
428
// * exp - limits results to only records for the given experiment (e.g., ndt)
429
//
430
// The "org" and "exp" query parameters are currently only supported by the
431
// default or "machines" format.
432
func (c *Client) Registrations(rw http.ResponseWriter, req *http.Request) {
2✔
433
        var err error
2✔
434
        var result interface{}
2✔
435

2✔
436
        setHeaders(rw)
2✔
437

2✔
438
        q := req.URL.Query()
2✔
439
        format := q.Get("format")
2✔
440

2✔
441
        switch format {
2✔
UNCOV
442
        case "geo":
×
UNCOV
443
                result, err = siteinfo.Geo(c.LocatorV2.Instances(), q)
×
444
        default:
2✔
445
                result, err = siteinfo.Hosts(c.LocatorV2.Instances(), q)
2✔
446
        }
447

448
        if err != nil {
3✔
449
                v2Error := v2.NewError("siteinfo", err.Error(), http.StatusInternalServerError)
1✔
450
                writeResult(rw, http.StatusInternalServerError, v2Error)
1✔
451
                return
1✔
452
        }
1✔
453

454
        writeResult(rw, http.StatusOK, result)
1✔
455
}
456

457
// checkClientLocation looks up the client location and copies the location
458
// headers to the response writer.
459
func (c *Client) checkClientLocation(rw http.ResponseWriter, req *http.Request) (*clientgeo.Location, error) {
15✔
460
        // Lookup the client location using the client request.
15✔
461
        loc, err := c.Locate(req)
15✔
462
        if err != nil {
17✔
463
                return nil, errFailedToLookupClient
2✔
464
        }
2✔
465

466
        // Copy location headers to response writer.
467
        for key := range loc.Headers {
37✔
468
                rw.Header().Set(key, loc.Headers.Get(key))
24✔
469
        }
24✔
470

471
        return loc, nil
13✔
472
}
473

474
// populateURLs populates each set of URLs using the target configuration.
475
func (c *Client) populateURLs(targets []v2.Target, ports static.Ports, exp string, pOpts paramOpts) {
10✔
476
        for i, target := range targets {
20✔
477
                token := c.getAccessToken(target.Machine, exp)
10✔
478
                params := c.extraParams(target.Machine, i, pOpts)
10✔
479
                targets[i].URLs = c.getURLs(ports, target.Hostname, token, params)
10✔
480
        }
10✔
481
}
482

483
// getAccessToken allocates a new access token using the given machine name as
484
// the intended audience and the subject as the target service.
485
func (c *Client) getAccessToken(machine, subject string) string {
11✔
486
        // Create the token. The same access token is reused for every URL of a
11✔
487
        // target port.
11✔
488
        // A uuid is added to the claims so that each new token is unique.
11✔
489
        cl := jwt.Claims{
11✔
490
                Issuer:   static.IssuerLocate,
11✔
491
                Subject:  subject,
11✔
492
                Audience: jwt.Audience{machine},
11✔
493
                Expiry:   jwt.NewNumericDate(time.Now().Add(time.Minute)),
11✔
494
                ID:       uuid.NewString(),
11✔
495
        }
11✔
496
        token, err := c.Sign(cl)
11✔
497
        // Sign errors can only happen due to a misconfiguration of the key.
11✔
498
        // A good config will remain good.
11✔
499
        rtx.PanicOnError(err, "signing claims has failed")
11✔
500
        return token
11✔
501
}
11✔
502

503
// getURLs creates URLs for the named experiment, running on the named machine
504
// for each given port. Every URL will include an `access_token=` parameter,
505
// authorizing the measurement.
506
func (c *Client) getURLs(ports static.Ports, hostname, token string, extra url.Values) map[string]string {
11✔
507
        urls := map[string]string{}
11✔
508
        // For each port config, prepare the target url with access_token and
11✔
509
        // complete host field.
11✔
510
        for _, target := range ports {
29✔
511
                name := target.String()
18✔
512
                params := url.Values{}
18✔
513
                params.Set("access_token", token)
18✔
514
                for key := range extra {
62✔
515
                        // note: we only use the first value.
44✔
516
                        params.Set(key, extra.Get(key))
44✔
517
                }
44✔
518
                target.RawQuery = params.Encode()
18✔
519

18✔
520
                host := &bytes.Buffer{}
18✔
521
                err := c.targetTmpl.Execute(host, map[string]string{
18✔
522
                        "Hostname": hostname,
18✔
523
                        "Ports":    target.Host, // from URL template, so typically just the ":port".
18✔
524
                })
18✔
525
                rtx.PanicOnError(err, "bad template evaluation")
18✔
526
                target.Host = host.String()
18✔
527
                urls[name] = target.String()
18✔
528
        }
529
        return urls
11✔
530
}
531

532
// limitRequest determines whether a client request should be rate-limited.
533
func (c *Client) limitRequest(now time.Time, req *http.Request) bool {
18✔
534
        agent := req.Header.Get("User-Agent")
18✔
535
        l, ok := c.agentLimits[agent]
18✔
536
        if !ok {
33✔
537
                // No limit defined for user agent.
15✔
538
                return false
15✔
539
        }
15✔
540
        return l.IsLimited(now)
3✔
541
}
542

543
// setHeaders sets the response headers for "nearest" requests.
544
func setHeaders(rw http.ResponseWriter) {
24✔
545
        // Set CORS policy to allow third-party websites to use returned resources.
24✔
546
        rw.Header().Set("Content-Type", "application/json")
24✔
547
        rw.Header().Set("Access-Control-Allow-Origin", "*")
24✔
548
        rw.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
24✔
549
        rw.Header().Set("Access-Control-Allow-Headers", "Authorization")
24✔
550
        // Prevent caching of result.
24✔
551
        // See also: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
24✔
552
        rw.Header().Set("Cache-Control", "no-store")
24✔
553
}
24✔
554

555
// writeResult marshals the result and writes the result to the response writer.
556
func writeResult(rw http.ResponseWriter, status int, result interface{}) {
27✔
557
        b, err := json.MarshalIndent(result, "", "  ")
27✔
558
        // Errors are only possible when marshalling incompatible types, like functions.
27✔
559
        rtx.PanicOnError(err, "Failed to format result")
27✔
560
        rw.WriteHeader(status)
27✔
561
        rw.Write(b)
27✔
562
}
27✔
563

564
// getExperimentAndService takes an http request path and extracts the last two
565
// fields. For correct requests (e.g. "/v2/nearest/ndt/ndt5"), this will be the
566
// experiment name (e.g. "ndt") and the datatype (e.g. "ndt5").
567
func getExperimentAndService(p string) (string, string) {
17✔
568
        datatype := path.Base(p)
17✔
569
        experiment := path.Base(path.Dir(p))
17✔
570
        return experiment, experiment + "/" + datatype
17✔
571
}
17✔
572

573
// getRemoteAddr extracts the remote address from the request. When running on
574
// Google App Engine, the X-Forwarded-For is guaranteed to be set. The GCP load
575
// balancer appends the actual client IP and the load balancer IP to any existing
576
// X-Forwarded-For header, so the second-to-last IP is the real client address.
577
// When running elsewhere (including on the local machine), the RemoteAddr from
578
// the request is used instead.
579
func getRemoteAddr(req *http.Request) string {
17✔
580
        xff := req.Header.Get("X-Forwarded-For")
17✔
581
        if xff != "" {
31✔
582
                // Split by comma and trim spaces from each IP
14✔
583
                ips := strings.Split(xff, ",")
14✔
584
                for i := range ips {
37✔
585
                        ips[i] = strings.TrimSpace(ips[i])
23✔
586
                }
23✔
587

588
                // GCP load balancer appends: <original-header>, <client-ip>, <lb-ip>
589
                // The second-to-last IP is the actual client address
590
                if len(ips) >= 2 {
18✔
591
                        return ips[len(ips)-2]
4✔
592
                } else if len(ips) == 1 && ips[0] != "" {
24✔
593
                        return ips[0]
10✔
594
                }
10✔
595
        }
596

597
        // Fall back to RemoteAddr for local testing or deployments outside of GAE.
598
        host, _, err := net.SplitHostPort(req.RemoteAddr)
3✔
599
        if err != nil {
4✔
600
                // As a last resort, use the whole RemoteAddr.
1✔
601
                return strings.TrimSpace(req.RemoteAddr)
1✔
602
        }
1✔
603
        return host
2✔
604
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc