-
Notifications
You must be signed in to change notification settings - Fork 15
/
mlablocatev2.go
272 lines (232 loc) · 7.53 KB
/
mlablocatev2.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
// Package mlablocatev2 implements m-lab locate services API v2.
package mlablocatev2
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"github.com/ooni/probe-engine/pkg/model"
"github.com/ooni/probe-engine/pkg/netxlite"
"github.com/ooni/probe-engine/pkg/runtimex"
)
// ndt7URLPath is the URL path to be used for ndt
const ndt7URLPath = "v2/nearest/ndt/ndt7"
// ErrRequestFailed indicates that the response is not "200 Ok"
var ErrRequestFailed = errors.New("mlablocatev2: request failed")
// ErrEmptyResponse indicates that no hosts were returned
var ErrEmptyResponse = errors.New("mlablocatev2: empty response")
// Client is a client for v2 of the locate services. Please use the
// NewClient factory to construct a new instance of client, otherwise
// you MUST fill all the fields marked as MANDATORY.
type Client struct {
// HTTPClient is the MANDATORY http client to use
HTTPClient model.HTTPClient
// Hostname is the MANDATORY hostname of the mlablocate API.
Hostname string
// Logger is the MANDATORY logger to use.
Logger model.Logger
// Scheme is the MANDATORY scheme to use (http or https).
Scheme string
// UserAgent is the MANDATORY user-agent to use.
UserAgent string
}
// NewClient creates a client for v2 of the locate services.
func NewClient(httpClient model.HTTPClient, logger model.Logger, userAgent string) *Client {
return &Client{
HTTPClient: httpClient,
Hostname: "locate.measurementlab.net",
Logger: logger,
Scheme: "https",
UserAgent: userAgent,
}
}
// entryRecord describes one of the machines returned by v2 of
// the locate service. It gives you the FQDN of the specific
// machine along with URLs for each experiment phase. You SHOULD
// use the URLs directly because they contain access tokens.
type entryRecord struct {
// Machine is the FQDN of the machine
Machine string `json:"machine"`
// URLs contains the URLs to use
URLs map[string]string `json:"urls"`
}
// siteRegexp is the regexp to extract the site from the
// machine name when the domain is a v2 domain.
//
// Example: mlab3-mil04.mlab-oti.measurement-lab.org.
var siteRegexp = regexp.MustCompile(
`^(mlab[1-4]d?)-([a-z]{3}[0-9tc]{2})\.([a-z0-9-]{1,16})\.(measurement-lab\.org)$`,
)
// Site returns the site name. If it is not possible to determine
// the site name, we return the empty string.
func (er entryRecord) Site() string {
m := siteRegexp.FindAllStringSubmatch(er.Machine, -1)
if len(m) != 1 || len(m[0]) != 5 {
return ""
}
return m[0][2]
}
// resultRecord is a result of a query to locate.measurementlab.net.
type resultRecord struct {
// Results contains the results.
Results []entryRecord `json:"results"`
}
// query queries the locate service.
func (c *Client) query(ctx context.Context, path string) (*resultRecord, error) {
// prepare the HTTP request
URL := &url.URL{
Scheme: c.Scheme,
Host: c.Hostname,
Path: path,
}
req, err := http.NewRequestWithContext(ctx, "GET", URL.String(), nil)
if err != nil {
return nil, err
}
req.Header.Add("User-Agent", c.UserAgent)
c.Logger.Debugf("mlablocatev2: GET %s", URL.String())
// send the HTTP request
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
// process the HTTP response
if resp.StatusCode != 200 {
return nil, fmt.Errorf("%w: %d", ErrRequestFailed, resp.StatusCode)
}
reader := io.LimitReader(resp.Body, 1<<20)
data, err := netxlite.ReadAllContext(ctx, reader)
if err != nil {
return nil, err
}
c.Logger.Debugf("mlablocatev2: %s", string(data))
// parse the JSON response body
var result resultRecord
if err := json.Unmarshal(data, &result); err != nil {
return nil, err
}
return &result, nil
}
// NDT7Result is the result of a v2 locate services query for ndt7.
type NDT7Result struct {
// Hostname is an informative field containing the hostname
// to which you're connected. Because there are access tokens,
// you CANNOT use this field directly.
Hostname string
// Site is an informative field containing the site
// to which the server belongs to.
Site string
// WSSDownloadURL is the WebSocket URL to be used for
// performing a download over HTTPS. Note that the URL
// typically includes the required access token.
WSSDownloadURL string
// WSSUploadURL is like WSSDownloadURL but for the upload.
WSSUploadURL string
}
// QueryNDT7 performs a v2 locate services query for ndt7.
func (c *Client) QueryNDT7(ctx context.Context) ([]*NDT7Result, error) {
// get the generic response from locate
out, err := c.query(ctx, ndt7URLPath)
if err != nil {
return nil, err
}
runtimex.Assert(out != nil, "expected non-nil out")
// process the results and assemble output used by NDT
var result []*NDT7Result
for _, entry := range out.Results {
r := NDT7Result{
WSSDownloadURL: entry.URLs["wss:///ndt/v7/download"],
WSSUploadURL: entry.URLs["wss:///ndt/v7/upload"],
}
if r.WSSDownloadURL == "" || r.WSSUploadURL == "" {
c.Logger.Warn("empty WSSDownloadURL or WSSUploadURL")
continue
}
// Implementation note: we extract the hostname from the
// download URL, under the assumption that the download and
// the upload URLs have the same hostname.
url, err := url.Parse(r.WSSDownloadURL)
if err != nil {
c.Logger.Warnf("cannot parse WSSDownloadURL: %s", err.Error())
continue
}
// assemble the full response
r.Hostname = url.Hostname()
r.Site = entry.Site()
result = append(result, &r)
}
// make sure we have at least one entry
if len(result) <= 0 {
return nil, ErrEmptyResponse
}
return result, nil
}
// DashResult is the result of a v2 locate services query for dash.
type DashResult struct {
// Hostname is an informative field containing the hostname
// to which you're connected. Because there are access tokens,
// you CANNOT use this field directly.
Hostname string
// Site is an informative field containing the site
// to which the server belongs to.
Site string
// NegotiateURL is the HTTPS URL to be used for
// performing the DASH negotiate operation. Note that this
// URL typically includes the required access token.
NegotiateURL string
// BaseURL is the base URL used for the download and the
// collect phases of dash. The token is only required during
// the negotiate phase and we can otherwise use a base URL.
BaseURL string
}
// dashURLPath is the URL path to be used for dash
const dashURLPath = "v2/nearest/neubot/dash"
// QueryDash performs a v2 locate services query for dash.
func (c *Client) QueryDash(ctx context.Context) ([]*DashResult, error) {
// get the generic response from locate
out, err := c.query(ctx, dashURLPath)
if err != nil {
return nil, err
}
runtimex.Assert(out != nil, "expected non-nil out")
// process the results and assemble output used by DASH
var result []*DashResult
for _, entry := range out.Results {
r := DashResult{
NegotiateURL: entry.URLs["https:///negotiate/dash"],
}
if r.NegotiateURL == "" {
c.Logger.Warn("empty NegotiateURL")
continue
}
url, err := url.Parse(r.NegotiateURL)
if err != nil {
c.Logger.Warnf("cannot parse NegotiateURL: %s", err.Error())
continue
}
// assemble the full response
r.Hostname = url.Hostname()
r.BaseURL = dashBaseURL(url)
r.Site = entry.Site()
result = append(result, &r)
}
// make sure we have at least one entry
if len(result) <= 0 {
return nil, ErrEmptyResponse
}
return result, nil
}
// dashBaseURL obtains the dash base URL from the negotiate URL.
func dashBaseURL(URL *url.URL) string {
out := &url.URL{
Scheme: URL.Scheme,
Host: URL.Host,
Path: "/",
}
return out.String()
}