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

m-lab / locate / 1631

20 Nov 2025 02:23PM UTC coverage: 91.121% (-0.1%) from 91.236%
1631

push

travis-pro

robertodauria
add more logging

14 of 16 new or added lines in 1 file covered. (87.5%)

4 existing lines in 1 file now uncovered.

2268 of 2489 relevant lines covered (91.12%)

1.01 hits per line

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

94.48
/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/google/uuid"
22
        log "github.com/sirupsen/logrus"
23
        "gopkg.in/square/go-jose.v2/jwt"
24

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

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

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

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

52
// Client contains state needed for xyz.
53
type Client struct {
54
        Signer
55
        project string
56
        LocatorV2
57
        ClientLocator
58
        PrometheusClient
59
        targetTmpl       *template.Template
60
        agentLimits      limits.Agents
61
        ipLimiter        Limiter
62
        earlyExitClients map[string]bool
63
        jwtVerifier      jwtverifier.JWTVerifier
64
}
65

66
// LocatorV2 defines how the Nearest handler requests machines nearest to the
67
// client.
68
type LocatorV2 interface {
69
        Nearest(service string, lat, lon float64, opts *heartbeat.NearestOptions) (*heartbeat.TargetInfo, error)
70
        heartbeat.StatusTracker
71
}
72

73
// ClientLocator defines the interface for looking up the client geolocation.
74
type ClientLocator interface {
75
        Locate(req *http.Request) (*clientgeo.Location, error)
76
}
77

78
// PrometheusClient defines the interface to query Prometheus.
79
type PrometheusClient interface {
80
        Query(ctx context.Context, query string, ts time.Time, opts ...prom.Option) (model.Value, prom.Warnings, error)
81
}
82

83
type paramOpts struct {
84
        raw       url.Values
85
        version   string
86
        ranks     map[string]int
87
        svcParams map[string]float64
88
}
89

90
func init() {
1✔
91
        log.SetFormatter(&log.JSONFormatter{})
1✔
92
        log.SetLevel(log.InfoLevel)
1✔
93
}
1✔
94

95
// NewClient creates a new client.
96
func NewClient(project string, private Signer, locatorV2 LocatorV2, client ClientLocator,
97
        prom PrometheusClient, lmts limits.Agents, limiter Limiter, earlyExitClients []string, jwtVerifier jwtverifier.JWTVerifier) *Client {
1✔
98
        // Convert slice to map for O(1) lookups
1✔
99
        earlyExitMap := make(map[string]bool)
1✔
100
        for _, client := range earlyExitClients {
1✔
101
                earlyExitMap[client] = true
×
102
        }
×
103
        return &Client{
1✔
104
                Signer:           private,
1✔
105
                project:          project,
1✔
106
                LocatorV2:        locatorV2,
1✔
107
                ClientLocator:    client,
1✔
108
                PrometheusClient: prom,
1✔
109
                targetTmpl:       template.Must(template.New("name").Parse("{{.Hostname}}{{.Ports}}")),
1✔
110
                agentLimits:      lmts,
1✔
111
                ipLimiter:        limiter,
1✔
112
                earlyExitClients: earlyExitMap,
1✔
113
                jwtVerifier:      jwtVerifier,
1✔
114
        }
1✔
115
}
116

117
// NewClientDirect creates a new client with a target template using only the target machine.
118
func NewClientDirect(project string, private Signer, locatorV2 LocatorV2, client ClientLocator, prom PrometheusClient) *Client {
1✔
119
        return &Client{
1✔
120
                Signer:           private,
1✔
121
                project:          project,
1✔
122
                LocatorV2:        locatorV2,
1✔
123
                ClientLocator:    client,
1✔
124
                PrometheusClient: prom,
1✔
125
                // Useful for the locatetest package when running a local server.
1✔
126
                targetTmpl: template.Must(template.New("name").Parse("{{.Hostname}}{{.Ports}}")),
1✔
127
        }
1✔
128
}
1✔
129

130
func (c *Client) extraParams(hostname string, index int, p paramOpts) url.Values {
1✔
131
        v := url.Values{}
1✔
132

1✔
133
        // Add client parameters.
1✔
134
        for key := range p.raw {
2✔
135
                if strings.HasPrefix(key, "client_") {
2✔
136
                        // note: we only use the first value.
1✔
137
                        v.Set(key, p.raw.Get(key))
1✔
138
                }
1✔
139

140
                val, ok := p.svcParams[key]
1✔
141
                if ok && rand.Float64() < val {
2✔
142
                        v.Set(key, p.raw.Get(key))
1✔
143
                }
1✔
144
        }
145

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

152
        // Add Locate Service version.
153
        v.Set("locate_version", p.version)
1✔
154

1✔
155
        // Add metro rank.
1✔
156
        rank, ok := p.ranks[hostname]
1✔
157
        if ok {
2✔
158
                v.Set("metro_rank", strconv.Itoa(rank))
1✔
159
        }
1✔
160

161
        // Add result index.
162
        v.Set("index", strconv.Itoa(index))
1✔
163

1✔
164
        return v
1✔
165
}
166

167
// Nearest uses an implementation of the LocatorV2 interface to look up
168
// nearest servers.
169
func (c *Client) Nearest(rw http.ResponseWriter, req *http.Request) {
1✔
170
        req.ParseForm()
1✔
171
        result := v2.NearestResult{}
1✔
172
        setHeaders(rw)
1✔
173

1✔
174
        if c.limitRequest(time.Now().UTC(), req) {
2✔
175
                result.Error = v2.NewError("client", tooManyRequests, http.StatusTooManyRequests)
1✔
176
                writeResult(rw, result.Error.Status, &result)
1✔
177
                metrics.RequestsTotal.WithLabelValues("nearest", "request limit", http.StatusText(result.Error.Status)).Inc()
1✔
178
                return
1✔
179
        }
1✔
180

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

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

213
        experiment, service := getExperimentAndService(req.URL.Path)
1✔
214

1✔
215
        // Look up client location.
1✔
216
        loc, err := c.checkClientLocation(rw, req)
1✔
217
        if err != nil {
2✔
218
                status := http.StatusServiceUnavailable
1✔
219
                result.Error = v2.NewError("nearest", "Failed to lookup nearest machines", status)
1✔
220
                writeResult(rw, result.Error.Status, &result)
1✔
221
                metrics.RequestsTotal.WithLabelValues("nearest", "client location",
1✔
222
                        http.StatusText(result.Error.Status)).Inc()
1✔
223
                return
1✔
224
        }
1✔
225

226
        // Parse client location.
227
        lat, errLat := strconv.ParseFloat(loc.Latitude, 64)
1✔
228
        lon, errLon := strconv.ParseFloat(loc.Longitude, 64)
1✔
229
        if errLat != nil || errLon != nil {
2✔
230
                result.Error = v2.NewError("client", errFailedToLookupClient.Error(), http.StatusInternalServerError)
1✔
231
                writeResult(rw, result.Error.Status, &result)
1✔
232
                metrics.RequestsTotal.WithLabelValues("nearest", "parse client location",
1✔
233
                        http.StatusText(result.Error.Status)).Inc()
1✔
234
                return
1✔
235
        }
1✔
236

237
        // Find the nearest targets using the client parameters.
238
        q := req.URL.Query()
1✔
239
        t := q.Get("machine-type")
1✔
240
        country := req.Header.Get("X-AppEngine-Country")
1✔
241
        sites := q["site"]
1✔
242
        org := q.Get("org")
1✔
243
        strict := false
1✔
244
        if qsStrict, err := strconv.ParseBool(q.Get("strict")); err == nil {
1✔
245
                strict = qsStrict
×
246
        }
×
247
        // If strict, override the country from the AppEngine header with the one in
248
        // the querystring.
249
        if strict {
1✔
250
                country = q.Get("country")
×
251
        }
×
252
        opts := &heartbeat.NearestOptions{Type: t, Country: country, Sites: sites, Org: org, Strict: strict}
1✔
253
        targetInfo, err := c.LocatorV2.Nearest(service, lat, lon, opts)
1✔
254
        if err != nil {
2✔
255
                result.Error = v2.NewError("nearest", "Failed to lookup nearest machines", http.StatusInternalServerError)
1✔
256
                writeResult(rw, result.Error.Status, &result)
1✔
257
                metrics.RequestsTotal.WithLabelValues("nearest", "server location",
1✔
258
                        http.StatusText(result.Error.Status)).Inc()
1✔
259
                return
1✔
260
        }
1✔
261

262
        pOpts := paramOpts{
1✔
263
                raw:       req.Form,
1✔
264
                version:   "v2",
1✔
265
                ranks:     targetInfo.Ranks,
1✔
266
                svcParams: static.ServiceParams,
1✔
267
        }
1✔
268
        // Populate target URLs and write out response.
1✔
269
        c.populateURLs(targetInfo.Targets, targetInfo.URLs, experiment, pOpts)
1✔
270
        result.Results = targetInfo.Targets
1✔
271
        writeResult(rw, http.StatusOK, &result)
1✔
272
        metrics.RequestsTotal.WithLabelValues("nearest", "success", http.StatusText(http.StatusOK)).Inc()
1✔
273
}
274

275
// Live is a minimal handler to indicate that the server is operating at all.
276
func (c *Client) Live(rw http.ResponseWriter, req *http.Request) {
1✔
277
        fmt.Fprintf(rw, "ok")
1✔
278
}
1✔
279

280
// Ready reports whether the server is working as expected and ready to serve requests.
281
func (c *Client) Ready(rw http.ResponseWriter, req *http.Request) {
1✔
282
        if c.LocatorV2.Ready() {
2✔
283
                fmt.Fprintf(rw, "ok")
1✔
284
        } else {
2✔
285
                rw.WriteHeader(http.StatusInternalServerError)
1✔
286
                fmt.Fprintf(rw, "not ready")
1✔
287
        }
1✔
288
}
289

290
// Registrations returns information about registered machines. There are 3
291
// supported query parameters:
292
//
293
// * format - defines the format of the returned JSON
294
// * org - limits results to only records for the given organization
295
// * exp - limits results to only records for the given experiment (e.g., ndt)
296
//
297
// The "org" and "exp" query parameters are currently only supported by the
298
// default or "machines" format.
299
func (c *Client) Registrations(rw http.ResponseWriter, req *http.Request) {
1✔
300
        var err error
1✔
301
        var result interface{}
1✔
302

1✔
303
        setHeaders(rw)
1✔
304

1✔
305
        q := req.URL.Query()
1✔
306
        format := q.Get("format")
1✔
307

1✔
308
        switch format {
1✔
309
        case "geo":
×
310
                result, err = siteinfo.Geo(c.LocatorV2.Instances(), q)
×
311
        default:
1✔
312
                result, err = siteinfo.Hosts(c.LocatorV2.Instances(), q)
1✔
313
        }
314

315
        if err != nil {
2✔
316
                v2Error := v2.NewError("siteinfo", err.Error(), http.StatusInternalServerError)
1✔
317
                writeResult(rw, http.StatusInternalServerError, v2Error)
1✔
318
                return
1✔
319
        }
1✔
320

321
        writeResult(rw, http.StatusOK, result)
1✔
322
}
323

324
// checkClientLocation looks up the client location and copies the location
325
// headers to the response writer.
326
func (c *Client) checkClientLocation(rw http.ResponseWriter, req *http.Request) (*clientgeo.Location, error) {
1✔
327
        // Lookup the client location using the client request.
1✔
328
        loc, err := c.Locate(req)
1✔
329
        if err != nil {
2✔
330
                return nil, errFailedToLookupClient
1✔
331
        }
1✔
332

333
        // Copy location headers to response writer.
334
        for key := range loc.Headers {
2✔
335
                rw.Header().Set(key, loc.Headers.Get(key))
1✔
336
        }
1✔
337

338
        return loc, nil
1✔
339
}
340

341
// populateURLs populates each set of URLs using the target configuration.
342
func (c *Client) populateURLs(targets []v2.Target, ports static.Ports, exp string, pOpts paramOpts) {
1✔
343
        for i, target := range targets {
2✔
344
                token := c.getAccessToken(target.Machine, exp)
1✔
345
                params := c.extraParams(target.Machine, i, pOpts)
1✔
346
                targets[i].URLs = c.getURLs(ports, target.Hostname, token, params)
1✔
347
        }
1✔
348
}
349

350
// getAccessToken allocates a new access token using the given machine name as
351
// the intended audience and the subject as the target service.
352
func (c *Client) getAccessToken(machine, subject string) string {
1✔
353
        // Create the token. The same access token is reused for every URL of a
1✔
354
        // target port.
1✔
355
        // A uuid is added to the claims so that each new token is unique.
1✔
356
        cl := jwt.Claims{
1✔
357
                Issuer:   static.IssuerLocate,
1✔
358
                Subject:  subject,
1✔
359
                Audience: jwt.Audience{machine},
1✔
360
                Expiry:   jwt.NewNumericDate(time.Now().Add(time.Minute)),
1✔
361
                ID:       uuid.NewString(),
1✔
362
        }
1✔
363
        token, err := c.Sign(cl)
1✔
364
        // Sign errors can only happen due to a misconfiguration of the key.
1✔
365
        // A good config will remain good.
1✔
366
        rtx.PanicOnError(err, "signing claims has failed")
1✔
367
        return token
1✔
368
}
1✔
369

370
// getURLs creates URLs for the named experiment, running on the named machine
371
// for each given port. Every URL will include an `access_token=` parameter,
372
// authorizing the measurement.
373
func (c *Client) getURLs(ports static.Ports, hostname, token string, extra url.Values) map[string]string {
1✔
374
        urls := map[string]string{}
1✔
375
        // For each port config, prepare the target url with access_token and
1✔
376
        // complete host field.
1✔
377
        for _, target := range ports {
2✔
378
                name := target.String()
1✔
379
                params := url.Values{}
1✔
380
                params.Set("access_token", token)
1✔
381
                for key := range extra {
2✔
382
                        // note: we only use the first value.
1✔
383
                        params.Set(key, extra.Get(key))
1✔
384
                }
1✔
385
                target.RawQuery = params.Encode()
1✔
386

1✔
387
                host := &bytes.Buffer{}
1✔
388
                err := c.targetTmpl.Execute(host, map[string]string{
1✔
389
                        "Hostname": hostname,
1✔
390
                        "Ports":    target.Host, // from URL template, so typically just the ":port".
1✔
391
                })
1✔
392
                rtx.PanicOnError(err, "bad template evaluation")
1✔
393
                target.Host = host.String()
1✔
394
                urls[name] = target.String()
1✔
395
        }
396
        return urls
1✔
397
}
398

399
// limitRequest determines whether a client request should be rate-limited.
400
func (c *Client) limitRequest(now time.Time, req *http.Request) bool {
1✔
401
        agent := req.Header.Get("User-Agent")
1✔
402
        l, ok := c.agentLimits[agent]
1✔
403
        if !ok {
2✔
404
                // No limit defined for user agent.
1✔
405
                return false
1✔
406
        }
1✔
407
        return l.IsLimited(now)
1✔
408
}
409

410
// setHeaders sets the response headers for "nearest" requests.
411
func setHeaders(rw http.ResponseWriter) {
1✔
412
        // Set CORS policy to allow third-party websites to use returned resources.
1✔
413
        rw.Header().Set("Content-Type", "application/json")
1✔
414
        rw.Header().Set("Access-Control-Allow-Origin", "*")
1✔
415
        // Prevent caching of result.
1✔
416
        // See also: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
1✔
417
        rw.Header().Set("Cache-Control", "no-store")
1✔
418
}
1✔
419

420
// writeResult marshals the result and writes the result to the response writer.
421
func writeResult(rw http.ResponseWriter, status int, result interface{}) {
1✔
422
        b, err := json.MarshalIndent(result, "", "  ")
1✔
423
        // Errors are only possible when marshalling incompatible types, like functions.
1✔
424
        rtx.PanicOnError(err, "Failed to format result")
1✔
425
        rw.WriteHeader(status)
1✔
426
        rw.Write(b)
1✔
427
}
1✔
428

429
// getExperimentAndService takes an http request path and extracts the last two
430
// fields. For correct requests (e.g. "/v2/nearest/ndt/ndt5"), this will be the
431
// experiment name (e.g. "ndt") and the datatype (e.g. "ndt5").
432
func getExperimentAndService(p string) (string, string) {
1✔
433
        datatype := path.Base(p)
1✔
434
        experiment := path.Base(path.Dir(p))
1✔
435
        return experiment, experiment + "/" + datatype
1✔
436
}
1✔
437

438
// getRemoteAddr extracts the remote address from the request. When running on
439
// Google App Engine, the X-Forwarded-For is guaranteed to be set. When running
440
// elsewhere (including on the local machine), the RemoteAddr from the request
441
// is used instead.
442
func getRemoteAddr(req *http.Request) string {
1✔
443
        xff := req.Header.Get("X-Forwarded-For")
1✔
444
        appEngineUserIP := req.Header.Get("X-AppEngine-User-IP")
1✔
445
        remoteAddr := req.RemoteAddr
1✔
446

1✔
447
        // Log all available IP sources
1✔
448
        log.Printf("[XFF-DEBUG] ========== IP Address Analysis ==========")
1✔
449
        log.Printf("[XFF-DEBUG] X-Forwarded-For:      %q", xff)
1✔
450
        log.Printf("[XFF-DEBUG] X-AppEngine-User-IP:  %q", appEngineUserIP)
1✔
451
        log.Printf("[XFF-DEBUG] RemoteAddr:           %q", remoteAddr)
1✔
452

1✔
453
        var ip string
1✔
454

1✔
455
        if xff != "" {
2✔
456
                // TODO: SECURITY VULNERABILITY - taking first IP allows spoofing!
1✔
457
                ip = strings.TrimSpace(strings.Split(xff, ",")[0])
1✔
458
                log.Printf("[XFF-DEBUG] Extracted IP (FIRST): %q ⚠️ VULNERABLE TO SPOOFING", ip)
1✔
459

1✔
460
                // Show what we SHOULD be using in production
1✔
461
                ips := strings.Split(xff, ",")
1✔
462
                if len(ips) >= 2 {
1✔
463
                        secureIP := strings.TrimSpace(ips[len(ips)-2])
×
NEW
464
                        log.Printf("[XFF-DEBUG] Should use (SECOND-TO-LAST): %q ✓ SECURE", secureIP)
×
NEW
465
                }
×
466

467
                // Show all IPs in the chain
468
                log.Printf("[XFF-DEBUG] Full IP chain (%d addresses):", len(ips))
1✔
469
                for i, addr := range ips {
2✔
470
                        log.Printf("[XFF-DEBUG]   [%d] %q", i, strings.TrimSpace(addr))
1✔
471
                }
1✔
472
        } else {
1✔
473
                // Fall back to RemoteAddr for local testing or deployments outside of GAE.
1✔
474
                host, _, err := net.SplitHostPort(req.RemoteAddr)
1✔
475
                if err != nil {
1✔
476
                        // As a last resort, use the whole RemoteAddr.
×
477
                        ip = strings.TrimSpace(req.RemoteAddr)
×
478
                } else {
1✔
479
                        ip = host
1✔
480
                }
1✔
481
                log.Printf("[XFF-DEBUG] No X-Forwarded-For, using RemoteAddr: %q", ip)
1✔
482
        }
483

484
        log.Printf("[XFF-DEBUG] Final IP used for rate limiting: %q", ip)
1✔
485
        log.Printf("[XFF-DEBUG] ==========================================")
1✔
486
        return ip
1✔
487
}
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

© 2025 Coveralls, Inc